Skip to content

useRef vs useState in Streaming UIs

I recently had a technical recruiter screen with a company whose product you’ve probably used today. One of the questions: when would you reach for useRef versus useState in a streaming context?

I gave a textbook answer. useState triggers re-renders, useRef doesn’t. The interviewer waited for more. I didn’t have more. The screen didn’t go well.

The thing is, I knew the API difference. I just hadn’t internalized what that difference costs you when chunks are arriving every 50 milliseconds. So I did what I always do when something doesn’t click: I built it until it did.

This post is the answer I should have given.

useState triggers a re-render when it updates. useRef stores a value that persists across renders without triggering one. In most React code, it barely matters which one you pick for values the user never sees. But in a streaming context, where state updates fire 20 times per second, picking wrong means jumbled output, race conditions, and scroll jank.

Start With the Naive Version

Here’s a basic streaming chat component. Everything lives in useState. It works.

const [messages, setMessages] = useState([]);
const [partialContent, setPartialContent] = useState("");
const [isStreaming, setIsStreaming] = useState(false);

// Auto-scroll on every update
useEffect(() => {
  scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [partialContent, messages]);
Naive version: everything in useState
Renders: 1
Assistant
Ask me anything about React and streaming UIs.

Send a few messages. Watch the render counter. Everything looks fine. The problems only surface under pressure.

What Breaks Under Pressure

Problem 1: No Cancel

Send a message. While it’s still streaming, send another one. In the naive version, both streams write to the same partialContent buffer. The output jumbles together.

Problem 1: No Cancel
What happens when you send a new message mid-stream?
Click the button below to see the problem
⚠ No AbortController: the first stream keeps writing after the second starts. Both write to the same partialContent buffer.

The fix is an AbortController stored in a useRef. When a new message comes in, abort the previous stream before starting the new one.

const abortRef = useRef(null);

const sendMessage = async (text) => {
  // Cancel previous stream
  if (abortRef.current) abortRef.current.abort();
  abortRef.current = new AbortController();

  const stream = fetchStream(text, abortRef.current.signal);
  // ...
};

Why a ref? You don’t render the AbortController. You don’t show it to the user. You just need to reference it when the next message arrives. Putting it in state would trigger a re-render every time you create a new one, and that re-render does nothing visible.

Problem 2: Race Condition

Send three messages in quick succession. Each response streams back at a different speed. The slowest response finishes last and overwrites the faster ones. Wrong order.

Problem 2: Race Condition
Rapid messages, responses arrive out of order
Click below to fire three messages in quick succession
⚠ No request ID tracking: all three streams write to partialContent. The slowest finishes last and commits out of order.

The fix is a request ID stored in a useRef. Increment it on each new request. Before writing a chunk, check: does this chunk’s request ID match requestIdRef.current? If not, discard it.

const requestIdRef = useRef(0);

const sendMessage = async (text) => {
  requestIdRef.current += 1;
  const myRequestId = requestIdRef.current;

  for await (const chunk of stream) {
    // Stale? Discard.
    if (requestIdRef.current !== myRequestId) return;
    setPartialContent((prev) => prev + chunk);
  }
};

This comparison happens inside an async callback, not during render. The value doesn’t drive UI. It drives a decision. That’s the ref use case.

Problem 3: Scroll Thrashing

The naive version auto-scrolls to the bottom on every chunk. If the user scrolls up to re-read an earlier message, they get yanked back down within 50 milliseconds.

Problem 3: Scroll Thrashing
Auto-scroll fights the user
You
How does React handle streaming updates?
Assistant
Great question. The short answer is: one chunk at a time, each triggering a state update and re-render. But the details matter a lot for performance.
You
Can you go deeper on that?
Assistant
Sure. Each chunk from the stream calls setState, which triggers a re-render. React diffs the virtual DOM, updates the real DOM, and the user sees the new text. At high frequency, this can get expensive.
You
What about scroll behavior during streaming?
Assistant
This is where it gets tricky. Most implementations auto-scroll to the bottom on every update. That works fine if the user is watching the stream come in. But if they scroll up to re-read something...
You
Keep going, explain the full picture.
⚠ Auto-scrolls on every chunk. Try scrolling up while streaming: you get yanked back down within 50 milliseconds.

The fix is a scroll position flag stored in a useRef. Track whether the user has scrolled up. If they have, stop auto-scrolling entirely and show a button to jump back down. Don’t try to guess if they’re “close enough” to the bottom.

const userScrolledUpRef = useRef(false);

const handleScroll = () => {
  const el = scrollRef.current;
  const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 1;
  userScrolledUpRef.current = !atBottom;
};

// Only auto-scroll if user hasn't scrolled up
useEffect(() => {
  if (!userScrolledUpRef.current) {
    scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
  }
}, [partialContent]);

The scroll flag changes on scroll events, which fire constantly as the user drags. If this were state, every pixel of scrolling would trigger a re-render on top of the streaming re-renders you’re already paying for. The user doesn’t see the flag. Only your auto-scroll logic reads it. That’s a ref.

All Three Together

Fixed versionall three useRef fixes applied
Renders: 1
Active refs:AbortControllerrequestIdscrollPosition
Assistant
Ask me anything about React and streaming UIs. Try sending multiple messages quickly, or scroll up while I'm responding.
✓ Send mid-stream to test cancel. Send rapidly to test race conditions. Scroll up during streaming to test scroll control.

Three refs. Same component. No jumbled output, no race conditions, no scroll fighting.

The render counter is still there. It’ll look similar to the naive version because the chunks you display still need re-renders. That’s correct. But watch the “Saved” counter when you send mid-stream or fire rapid messages. Those are the setPartialContent calls that never happened because a ref caught them first.

TL;DR

If you’re reading a value during render to show the user something: useState.

If you’re reading a value inside a callback to make a decision: useRef.

This applies everywhere in React. Streaming just makes the cost of getting it wrong impossible to ignore.

About the Author

I’m Rachel Cantor, a product engineer with over 14 years of experience building production systems. I plan and implement technical architecture that requires a knack for detail and a focus on high-fidelity user experiences. Currently seeking contract opportunities and potentially the right full time opportunity.

Feel free to reach out to me on bear.ink or LinkedIn if you’re looking to build something sharp. 🙌


Next Post
Why Banning useEffect Is Really About Agents