Working with Refs & PortalsEntry 2 of 6
State vs Refs (and Refs for Non-UI Values)
State and refs both persist values across renders, but only state triggers a re-render when it changes.
Notes
| Aspect | State (useState) | Ref (useRef) |
|---|---|---|
| Changing it re-executes the component | Yes | No |
| Use for | Values directly reflected in the UI | "Behind the scenes" values with no direct UI impact, and DOM access |
| Updated via | Setter function | Assigning .current |
| Example | Form values, counters, selected tab | Timer IDs, previous values, DOM nodes |
A ref is also the right place for any mutable value that must survive re-renders without causing them, such as a timer ID. A plain let variable inside the component resets on every render, and a module-level variable is shared by every instance of the component.
Rule of thumb: use useRef for imperative reads and writes; use useState for reactive data that drives the UI.
What / Why / When
- What
Choosing between reactive state and a mutable ref.
- Why
Putting non-UI values in state causes wasted renders; putting UI values in refs causes a stale UI.
- When
Any time you store a value in a component, ask: "Does the screen need to change when this changes?"
- Use case
A countdown challenge that stores its timer ID in a ref and its running status in state.
Code compare
import { useRef, useState } from "react";export function TimerChallenge({ targetTime }: { targetTime: number }) { const timer = useRef<number | null>(null); // behind the scenes: no re-render needed const [isRunning, setIsRunning] = useState(false); // shown in the UI function handleStart() { timer.current = window.setTimeout(() => setIsRunning(false), targetTime * 1000); setIsRunning(true); } function handleStop() { if (timer.current !== null) clearTimeout(timer.current); setIsRunning(false); } return ( <button onClick={isRunning ? handleStop : handleStart}> {isRunning ? "Stop" : "Start"} challenge </button> );}A component class is instantiated once, so a plain field already persists. Only signals are reactive.
import { Component, DestroyRef, inject, input, signal } from "@angular/core";@Component({ selector: "app-timer-challenge", template: ` <button (click)="isRunning() ? stop() : start()"> {{ isRunning() ? "Stop" : "Start" }} challenge </button> `,})export class TimerChallengeComponent { targetTime = input.required<number>(); isRunning = signal(false); // shown in the UI private timer?: ReturnType<typeof setTimeout>; // plain field: the "ref" equivalent constructor() { inject(DestroyRef).onDestroy(() => clearTimeout(this.timer)); } start() { this.timer = setTimeout(() => this.isRunning.set(false), this.targetTime() * 1000); this.isRunning.set(true); } stop() { clearTimeout(this.timer); this.isRunning.set(false); }}Differences and gotchas
- Angular needs no
useReffor non-UI values, because class fields already survive change detection. The React split of "state vs ref" becomes "signal vs plain field" in Angular. - Clean up timers when the component is destroyed:
useEffectcleanup in React (later chapter),DestroyRefin Angular.
Quick reference
| React | Angular |
|---|---|
useState (reactive) | signal() |
useRef (mutable, non-reactive) | Plain class field |
ref.current = x | this.field = x |