Skip to the content.

Build a Counter component

Every other coding question on this site tests a plain function. This one renders a component and asserts on the DOM it produced.

Build Counter:

The workspace gives you a component runtime. Import what you need:

import { html, useState } from "@runtime";

export default function Counter({ start = 0 }) {
  const [count, setCount] = useState(start);
  return html`<output class="counter__value">${count}</output>`;
}

html is a tagged template, not JSX — same shape, nothing to transpile, which is what lets this run on a static site with no build step. Interpolate with ${...}, close components with <//> or <${Name} ... />.

@runtime is a bare specifier resolved by an import map on this page. Your code runs from a blob: URL, and blob: is not a hierarchical scheme — so a path like /assets/vendor/runtime.mjs has no base to resolve against and throws. Bare specifiers go through the document’s import map instead, which is why the import looks like a package name on a site with no package manager.

Workspace

Edit the module below and run the question's tests. Press Ctrl+Enter (or Cmd+Enter on a Mac) to run from the editor. Your draft is kept in this browser.

Solution

Approach 1: clamp on write

Hold the number in state and clamp inside the updater, so the invariant lives in one place:

const [count, setCount] = useState(start);
const clamp = (n) => Math.min(max, Math.max(min, n));
// ...
onClick=${() => setCount((c) => clamp(c + step))}

The functional updater matters. setCount(clamp(count + step)) reads count from the closure of the render that created the handler, so two clicks in the same tick both compute from the same stale value and the second is lost. Every component framework has this trap and the fix is the same everywhere.

Approach 2: derive the disabled state rather than store it

disabled=${count + step > max}

A second piece of state (const [canIncrease, setCanIncrease] = useState(true)) has to be kept in step with the first, and it will drift — someone adds a reset path and updates one of them. Deriving it at render time cannot drift, and costs one comparison.

Trade-offs

Clamping on write vs rejecting the click. Clamping means a click near the limit still moves, just less far. Rejecting means it does nothing. Clamping is friendlier for a slider-like control; rejecting is clearer for a quantity picker where 9 → 10 when the max is 10 would surprise. Either is defensible and the tests here take clamping, because it pairs with the disabled state to make the limit visible before it is reached.

<output> vs <span>. <output> is in the live region family, so a screen reader announces the new number without any ARIA. A <span> is silent, and the usual fix — adding aria-live — is one more attribute doing what the right element does for free.

Disabling vs hiding. A disabled button keeps the layout stable and tells the user the limit exists. Hiding it makes the row jump and leaves them guessing why the control vanished.