본문으로 건너뛰기
KYH
  • Blog
  • About

joseph0926

I document what I learn while solving product problems with React and TypeScript.

HomeBlogAbout

© 2026 joseph0926. All rights reserved.

reactuseRefrendering

useRef holds values not needed for rendering, so why should you not read it during render?

Two sentences in the React docs looked contradictory at first. useRef stores values not needed for rendering, yet React says not to read ref.current during render. Tracing the source code shows these are about two different dimensions of rendering: trigger vs consistency, all the way to tearing.

Mar 01, 20266 min read
useRef holds values not needed for rendering, so why should you not read it during render?

useRef holds values not needed for rendering, so why shouldn't you read it during render?

While reading the React docs for useRef, I got stuck between two sentences.

useRef is a React Hook that lets you reference a value that's not needed for rendering.

Do not write or read ref.current during rendering, except for initialization. This makes your component's behavior unpredictable.

If a value is not needed for rendering, why should reading it during render be forbidden? It sounded like “a value that does not affect rendering can still affect rendering,” which felt contradictory.

After tracing the source code, it turned out not to be a contradiction. The word “rendering” points to different things in those two sentences.


Two dimensions of “rendering”

The two sentences are talking about different dimensions.

SentenceDimensionMeaning
"Not needed for rendering"TriggerChanging ref.current does not trigger a re-render
"Do not read during rendering"Consistency

In concurrent rendering, ref values are not guaranteed to stay stable during a render

The first sentence is about trigger behavior: changing ref.current does not cause a re-render. useState has a path that tells React “render again” through setState, but useRef has no such path.

The second sentence is about consistency in concurrent rendering: when rendering is interrupted and resumed, ref.current can change in the middle. Because it is mutable data outside React’s control, each render attempt can read a different value.

So the same word “rendering” points to different mechanisms. “Does not trigger rendering” and “safe to read during rendering” are separate properties. We can verify both in source.


Structural difference in source code

useRef — no update queue

The internal implementation of useRef is simple.

// packages/react-reconciler/src/ReactFiberHooks.js:2602-2612
function mountRef<T>(initialValue: T): {current: T} {
  const hook = mountWorkInProgressHook();
  const ref = {current: initialValue};
  hook.memoizedState = ref;
  return ref;
}

function updateRef<T>(initialValue: T): {current: T} {
  const hook = updateWorkInProgressHook();
  return hook.memoizedState;
}

It just creates a {current: initialValue} object and stores it in the hook. There is no queue, no dispatch, and no path to call scheduleUpdateOnFiber.

useState — update queue + scheduleUpdateOnFiber

Now compare that to mountState.

// packages/react-reconciler/src/ReactFiberHooks.js:1894-1933
function mountStateImpl<S>(initialState): Hook {
  const hook = mountWorkInProgressHook();
  hook.memoizedState = hook.baseState = initialState;
  const queue: UpdateQueue = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: initialState,
  };
  hook.queue = queue;
  return hook;
}

function mountState<S>(initialState) {
  const hook = mountStateImpl(initialState);
  const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

useState notifies React through dispatch -> dispatchSetState -> scheduleUpdateOnFiber. useRef has no such path at all. That is the real meaning of “not needed for rendering” in the docs: even if you mutate ref.current, there is no mechanism to schedule a re-render.

That covers the trigger dimension. Now to consistency.


What goes wrong if you read during render

Reading values not integrated into React’s render pipeline creates problems at three levels.

Problem 1: It does not show up on screen

function Counter() {
  const countRef = useRef(0);

  return (
    <div>
      <p>{countRef.current}</p>
      <button
        onClick={() => {
          countRef.current++;
        }}
      >
        +1
      </button>
    </div>
  );
}

Clicking the button does not update the UI. countRef.current changes, but it does not trigger a re-render. This is by design in useRef.

Problem 2: Accidental reflection

It gets worse when a re-render happens for an unrelated reason.

function Dashboard() {
  const cachedDataRef = useRef(null);
  const [userName, setUserName] = useState('Kim');

  return (
    <div>
      <h1>{userName}</h1>
      {cachedDataRef.current ? (
        <DataView data={cachedDataRef.current} />
      ) : (
        <Loading />
      )}
    </div>
  );
}
  1. cachedDataRef.current = null -> <Loading /> is shown.
  2. Fetch completes -> cachedDataRef.current = data in an event handler (no re-render).
  3. setUserName("Park") -> re-render happens.
  4. Only now does <DataView> appear.

The timing of UI updates depends on unrelated state changes. If userName never changes, data may never appear. This creates bugs that are very hard to debug because display timing is outside explicit control.

Problem 3: Tearing

The most serious problem appears in concurrent rendering. React can split rendering into time slices. It can render the upper part of the tree, yield, and later continue with the lower part.

function Parent() {
  const sharedRef = useRef('A');

  return (
    <>
      <Child1 sharedRef={sharedRef} />
      {/* yield here, event happens, sharedRef.current = "B" */}
      <Child2 sharedRef={sharedRef} />
    </>
  );
}

Child1 renders in time slice 1 and reads ref.current = "A". Then an event updates it to "B". Child2 renders in time slice 2 and reads ref.current = "B".

Same render pass, different values. One part of the UI sees “A,” another sees “B.” That is tearing.

useState does not have this issue because React fixes a state snapshot at the start of render. This is exactly why useSyncExternalStore exists for safely reading external mutable values in render. It checks whether the snapshot changed during render via checkIfSnapshotChanged (ReactFiberHooks.js:1876), and forces a synchronous re-render if needed.

LevelSymptomSeverity
Problem 1Ref changes are not reflected in UILow (intended behavior)
Problem 2Values appear only when unrelated state changes happenMedium (hard to debug)
Problem 3Tearing: different values in the same renderHigh (breaks UI consistency)

Why is initialization an exception?

The docs explicitly allow an exception: “except for initialization.” This does not mean concurrent rendering is turned off during initialization. It still works the same way. The exception is allowed because of idempotency.

function Example() {
  const ref = useRef(null);
  if (ref.current === null) {
    ref.current = new ExpensiveObject();
  }
  // ...
}

In this pattern, ref.current only moves one way: null -> value. Even if React discards and retries render, once ref.current has a value the condition is skipped. No matter how many retries happen, the result stays the same.

Compared with regular “read ref during render,” the difference is clear.

[Normal read during render]
render#1: ref.current = "A" -> JSX("A")
-- React discards render#1, event handler sets ref.current = "B" --
render#2: ref.current = "B" -> JSX("B")   <- different result!

[Initialization pattern]
render#1: ref.current === null -> ref.current = new X() -> JSX(...)
-- React discards render#1 --
render#2: ref.current !== null -> keep existing value -> JSX(...)  <- same result

React test code explicitly allows this pattern.

// packages/react-reconciler/src/__tests__/useRef-test.internal.js:158-180
it('should not warn about lazy init during render', async () => {
  function Example() {
    const ref1 = useRef(null);
    const ref2 = useRef(undefined);
    // Read: safe because lazy init:
    if (ref1.current === null) {
      ref1.current = 123;
    }
    if (ref2.current === undefined) {
      ref2.current = 123;
    }
    return null;
  }

  await act(() => {
    ReactNoop.render(<Example />);
  });

  // Should not warn after an update either.
  await act(() => {
    ReactNoop.render(<Example />);
  });
});

The React team even left the comment // Read: safe because lazy init:. It is not “safe because concurrent rendering is off,” but “safe because retries still produce the same result in concurrent rendering.”


Why isn't this rule enforced by code?

It is also worth asking why such a risky rule is not enforced by runtime errors.

A similar rule, “state must be immutable,” is also not strictly enforced by code.

const [user, setUser] = useState({ name: 'Kim' });
user.name = 'Park'; // no JS error, no React error

Even with direct mutation, JavaScript does not block it and React runtime does not throw. The mutation itself is not detected by React, so the UI does not update immediately. (If another re-render happens for a different reason, it can show up later.)

State immutability ruleDo not read refs for render output
Enforced by JSX (objects are freely mutable)X (ref.current can be freely read)
Enforced by React runtimeX (no error on mutation)X (no error on reads)
DEV warningStrictMode double render can make symptoms easier to noticePre-detection via ESLint rule (react-hooks/refs)
If you violate itReact can miss the change -> no immediate UI updateAccidental reflection, tearing

Both break React’s core assumption: “render is a pure function.” Since JavaScript cannot block these patterns at language level, React relies on documentation and lint rules. Keeping that assumption is the developer’s responsibility.


Wrap-up

Back to the original question:

“Not needed for rendering,” yet “do not read during rendering” -- is this contradictory?

It is not. The two sentences are the two sides of one design intent. “Made for use outside rendering” (feature definition) naturally implies “do not use it to decide render output” (usage rule).

useState

useRef (read during render)

Value change -> re-render

Guaranteed (scheduleUpdateOnFiber)

No trigger path
Consistency in same renderGuaranteed (fixed snapshot)Not guaranteed (mutable)
Concurrent tearingPreventedExposed

The structure of mountRef (ReactFiberHooks.js:2602) shows this clearly: no update queue, no dispatch, and intentionally no scheduleUpdateOnFiber path. The code itself reflects the design decision not to connect refs to the render scheduling pipeline.

The moment this value starts affecting render output, it leaves React’s safety boundaries: fixed snapshots, bailout optimizations, tearing prevention, and more. So “do not read it” more precisely means: do not use it to decide render output.