Dice Roller
by Pratik Rai
Read the full write-upBuild a dice roller. The player picks how many dice to roll, presses Roll, and sees the faces, the total, and a short history of recent totals.
The Die component that draws the pips is given to you.
Requirements
Rolling gives every die a fresh random value from 1 to 6.
The number of dice follows the dropdown — picking 4 and rolling shows four dice.
The total under the dice reflects the faces currently shown.
Keep a history of the last five totals, most recent first.
The total is announced to assistive tech when it changes (
aria-live).
Notes
The pip layout is already solved by
DieandPIP_CELLS. This problem is about deriving state.Changing the dropdown does not need to re-roll immediately — the next roll picking up the new count is enough.
Hints
Dice Roller (reference solution)
One way to do it, not the only way. Yours passes if it renders.
Solution files
1import { useState } from 'react';
2
3// Pip positions per face, as grid cells 1-9. Given to you — the interesting
4// part of this problem is the state, not the dots.
5const PIP_CELLS = {
6 1: [5],
7 2: [1, 9],
8 3: [1, 5, 9],
9 4: [1, 3, 7, 9],
10 5: [1, 3, 5, 7, 9],
11 6: [1, 3, 4, 6, 7, 9],
12};
13
14function Die({ value }) {
15 const cells = PIP_CELLS[value] || [];
16 return (
17 <div className="die" role="img" aria-label={'Die showing ' + value}>
18 {Array.from({ length: 9 }, (_, i) => {
19 const cell = i + 1;
20 return cells.includes(cell) ? (
21 <span className="pip" key={cell} />
22 ) : (
23 <span key={cell} />
24 );
25 })}
26 </div>
27 );
28}
29
30const rollDie = () => Math.floor(Math.random() * 6) + 1;
31
32export default function App() {
33 const [count, setCount] = useState(2);
34 const [values, setValues] = useState(() => [rollDie(), rollDie()]);
35 const [history, setHistory] = useState([]);
36
37 const roll = () => {
38 // Built from `count`, not from the previous values, so changing the number
39 // of dice takes effect on the very next roll without any extra syncing.
40 const next = Array.from({ length: count }, rollDie);
41 setValues(next);
42 setHistory((prev) =>
43 [next.reduce((sum, n) => sum + n, 0), ...prev].slice(0, 5)
44 );
45 };
46
47 const total = values.reduce((sum, n) => sum + n, 0);
48
49 return (
50 <div>
51 <h1>Dice roller</h1>
52
53 <div className="controls">
54 <label htmlFor="count">Dice</label>
55 <select
56 id="count"
57 value={count}
58 onChange={(event) => setCount(Number(event.target.value))}
59 >
60 {[1, 2, 3, 4, 5].map((n) => (
61 <option key={n} value={n}>{n}</option>
62 ))}
63 </select>
64 <button className="roll" onClick={roll}>Roll</button>
65 </div>
66
67 <div className="dice">
68 {values.map((value, i) => (
69 // Dice have no identity beyond their position, and the whole array is
70 // replaced on every roll, so the index is the honest key here.
71 <Die key={i} value={value} />
72 ))}
73 </div>
74
75 <p className="total" aria-live="polite">
76 Total: <strong>{total}</strong>
77 </p>
78
79 {history.length > 0 && (
80 <p className="history">Last rolls: {history.join(', ')}</p>
81 )}
82 </div>
83 );
84}How it works
The lesson here is what does not belong in state. The total and the dice count are both derivable — the total from the values array, the number of dice from the dropdown — so storing them separately creates a second source of truth that can disagree with the first. Every "why is the total wrong" bug in a component like this comes from exactly that.
Building the new roll from count rather than from values.length is the small decision that makes the dropdown work for free. There is no effect syncing the array length to the select, because the array is rebuilt from count each time.
The history uses [next, ...prev].slice(0, 5) rather than push, because a state array must be replaced, not edited. push returns a length and mutates in place, so React sees the same reference and skips the render.