ReactKeyDo

Truth in a Ref, Snapshot for React: A Per-Character Typing Engine

A KeyDo postmortem: typing is a high-frequency keyboard event, and shoving it straight into React state gets you burned by StrictMode and async setState. Keep the truth in a ref, commit a snapshot to render.

In KeyDo, the keyboard lessons and speed test share one typing engine, useTypingEngine: it listens to global keystrokes, judges each character right or wrong, and tracks the cursor. It sounds like “press a key, update some state,” but writing it that way in React steps on two traps. This post is about going around them.

Why typing can’t go straight into state

Typing is a high-frequency event — every keystroke reads the current cursor, judges correctness, and advances. The problem is twofold:

  1. setState is async. You do setPos(pos + 1) in handleKey, then try to read the new pos right after — and get the old value. On a fast burst, updates based on stale closure values clobber each other.
  2. StrictMode double-invokes the updater. In dev mode, React deliberately calls a setState(updater) function twice to help you catch side effects. If you put “judge correctness, increment keystrokes” logic inside the updater, it runs twice — error counts double, tallies go wrong.

The conclusion: typing, a “tightly coupled read-modify-write high-frequency state,” shouldn’t be carried directly by React’s async state.

Truth in a ref, rendering via snapshot

The fix splits state in two:

  • The truth lives in a useRef (stateRef.current) — cursor pos, per-character charStates, startedAt, keystrokes, errors. It’s synchronous, mutable, immediately readable;
  • Rendering uses a snapshot. Every time the event handler finishes mutating the ref, it calls commit() once to shallow-copy the current state into a new object and setSnap it out, triggering a re-render.
function handleKey(e) {
  const st = stateRef.current          // read the latest truth directly
  if (!st || st.finished) return
  // …judge correctness, advance pos, increment counts, all synchronously on st…
  const correct = normalizeKey(e.key) === text[st.pos]
  st.keystrokes += 1
  if (!correct) st.errors += 1
  st.charStates[st.pos] = correct ? 'correct' : 'incorrect'
  st.pos += 1
  if (st.pos >= text.length) st.finished = true
  commit()                             // done mutating, commit a snapshot to render
}

All judgment and counting happens synchronously in the event handler, in one pass reading the latest values; React only takes the snapshot and paints. commit() does charStates.slice() to copy a fresh array — because React uses reference comparison to decide whether to re-render, and mutating the original array in place is invisible to it. No side effects in the updater; they all stay in the event handler, so StrictMode’s double invocation doesn’t touch them.

Three character states and a lazy start

Each character has three states: pending (not typed yet), correct, incorrect. Backspace moves the cursor back one and resets that position to pending — allowing correction, but the keystrokes and errors counts don’t roll back (wrong is wrong; accuracy reflects it honestly).

Timing is lazy: startedAt starts as null, and only records Date.now() on the first actual keystroke. So the time spent “staring at the prompt on an open page” doesn’t count toward WPM — the clock starts the moment you move.

The finish callback, and why it takes a detour through an effect

Type the last character, and you need to tell the parent to tally up. The instinct is to call onFinish() right in handleKey — but that’s synchronously updating the parent’s state during a component’s event handling, and React complains about a cascading update.

So the finish signal goes through the snapshot too: handleKey only commits st.finished = true into the snapshot, and a separate effect watches it:

useEffect(() => {
  if (snap.finished) onFinishRef.current?.()
}, [snap.finished])

finished flips from false to true, the effect fires, and by now we’ve left the original event-handling context, so calling onFinish is clean. Likewise, callbacks like onKey / onFinish are stored in refs holding the latest version (onFinishRef.current = onFinish), so the keydown effect needn’t list them as dependencies and constantly unbind/rebind the listener.

Takeaways

Building a high-frequency per-character typing engine in React:

  • Don’t let React state carry high-frequency read-modify-write: async setState can’t read the latest value, and fast typing clobbers itself;
  • Truth in a ref, render via snapshot: mutate the ref synchronously in the event handler, then commit a shallow copy to drive the re-render;
  • No side effects in the updater: StrictMode double-invokes it, so keep judgment and counting in the event handler;
  • Lazy timing, non-rolling-back counts: start the clock on the first keystroke, let Backspace correct but still record the error;
  • Detour the finish callback through an effect: use a state flag + effect, to avoid synchronously updating the parent during event handling.

In one line: React does declarative rendering, the ref handles imperative high-frequency input — let each do its job, and don’t make them fight inside an updater.

Comments

  • Loading…

Comments are reviewed before publishing; email is visible only to me.