Skip to solution
easyFrontend

How do you manage component-level state without `useState` or `useReducer`?

280 views
01

Understand the problem

Question presented to candidate: "Suppose you cannot use useState or useReducer. What other ways are there to hold state that belongs to a component?"

What a strong answer should cover:

  • The historical answer: class components with this.state and this.setState. Still fully supported in React 19 — verified working.
  • this.setState merges partial state, unlike the hook setters which replace; and it takes an optional callback that runs after the commit.
  • useRef for values that must persist across renders but must not trigger one — timer ids, previous values, DOM nodes, instance-like flags.
  • The key distinction: state drives rendering; a ref does not. If the UI must react to the value, it is state.
  • useSyncExternalStore for reading state that lives outside React entirely.
  • useState and useReducer are two faces of one primitive, so "without either" really means "outside the hooks state model".
  • Anti-patterns: a module-level variable (shared by every instance and invisible to React), or mutating a ref and expecting a re-render.
  • Practical framing: this question is usually probing whether you understand when a value should cause a render, not whether you can recite class syntax.

Clarifying questions expected:

  • "Does the UI need to update when this value changes?" — that single question decides state versus ref.
  • "Is this a legacy codebase, or are we designing something new?"

Code / implementation expected: Yes — a class component with setState beside a useRef example showing why the ref does not re-render.

state managementclass componentslegacysetstate
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Target Audience: Engineers preparing for React interviews — assumes hooks basics. Difficulty: Easy to Medium

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. The class-component behaviour in section 3 was verified by rendering and cli

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

Class setState, a ref that does not render, and the module-variable trap
Run Playground
import { Component, useState, useRef } from "react";

// ── 1. The classic answer: a class component ──────────────────────────────
class ClassCounter extends Component {
  state = { count: 0, label: "class state" };

  bump = () => {
    // Note: setState MERGES. label survives without being mentioned.
    this.setState(
      (s) => ({ count: s.count + 1 }),
      // A post-commit callback — no hook equivalent; use an effect instead.
      () => console.log("committed, count is now", this.state.count),
    );
  };

  render() {
    return (
      <Row title="class this.setState">
        {this.state.label}: <strong>{this.state.count}</strong>{" "}
        <button onClick={this.bump}>+1</button>
      </Row>
    );
  }
}

// ── 2. useRef: persists, but deliberately never re-renders ────────────────
function RefCounter() {
  const clicks = useRef(0);
  const renders = useRef(0);
  const [, forceRender] = useState(0);
  renders.current++;

  return (
    <Row title="useRef">
      ref value: <strong>{clicks.current}</strong> · renders:{" "}
      <strong>{renders.current}</strong>{" "}
      <button onClick={() => { clicks.current++; }}>
        mutate ref (no re-render)
      </button>{" "}
      <button onClick={() => forceRender((n) => n + 1)}>force a render</button>
    </Row>
  );
}

// ── 3. The trap: a module-level variable ──────────────────────────────────
// Shared by EVERY instance, and React has no idea it changed.
let shared = 0;
function ModuleVarCounter({ name }) {
  const [, forceRender] = useState(0);
  return (
    <Row title={"module variable (" + name + ")"}>
      value: <strong>{shared}</strong>{" "}
      <button onClick={() => { shared++; }}>mutate (UI will not update)</button>{" "}
      <button onClick={() => forceRender((n) => n + 1)}>force a render</button>
    </Row>
  );
}

function Row({ title, children }) {
  return (
    <div style={{ border: "1px solid #ddd", borderRadius: 8, padding: 12, marginBottom: 10 }}>
      <div style={{ fontSize: 12, color: "#666", marginBottom: 4 }}>{title}</div>
      {children}
    </div>
  );
}

export default function App() {
  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6 }}>
      <ClassCounter />
      <RefCounter />
      <ModuleVarCounter name="instance one" />
      <ModuleVarCounter name="instance two" />
      <p style={{ color: "#666", fontSize: 13 }}>
        Mutate the ref a few times, then force a render — the number jumps to
        where it already was. Do the same on either module-variable row and
        both rows jump together, because they share one value.
      </p>
    </div>
  );
}
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 21 of 119 decoded in the React.js track. One more won't hurt.

Back to track