πΉοΈ Building 'Simple' React Games
Before we jummpppp in, I wouldn't actually recommend building games in React. For anything serious, reach for a 2D engine like Phaser or PixiJS, or a full engine like Unity or Unreal.
Also, these fixes are brought to you by our sponsor, Claude Code. Kidding (mostly). Genuinely had a blast orchestrating the refactor, steering it in the right direction, and picking up a bunch along the way.
Plenty didn't make it in either. The Next.js API side has its own rabbit hole: highscore submissions with a bit of server-side anti-cheat, top-10 deduping against Supabase via Prisma, and the edge cases you only notice once you start trying to break your own form.
And the classic Next.js dev-mode gotcha: every save hot-reloads your API route and spins up a fresh PrismaClient, each with its own connection pool. After enough saves Postgres taps out with "too many connections". Fix is a ten-line singleton that stashes the client on globalThis in dev. One for another day.
If this article makes your brain hurt , just give the games a whirl. Colour Match on insane is like trying to rub your belly and pat your head. Too much lol.
I wanted an excuse to play with Next.js API routes, Prisma, and Supabase end-to-end, and games felt like a fun way to do it. So I built two browser games for this site, Colour Match and Emoji React. No plan, I just winged it. By the end I had 18 useState calls, three competing setInterval timers, stale closures, timer drift, ghost rounds, and a Math.random() in render that was flipping emoji types between taps. I went back and refactored both of them, and here's what the cleanup came down to.
How the games work
Both games follow the same basic pattern. You render a grid of cells, start a timer that updates which cell is active, and the player taps the right one to score points. Here's a simple version that does exactly that:
It works fine for what it is. The interval picks a new active cell every tick, the player tries to tap it before it moves on, and a cleanup effect tears it all down on unmount. At this scale pretty much any approach works. Once you start adding streak scoring, round timers, difficulty levels, and highscore submission though, those four useState calls turn into eighteen, one interval turns into three, and the cleanup story stops being a one-liner.
Colour Match and Emoji React both started like this. By the time I'd added everything I wanted, I had 18 useState calls, three competing intervals, and bugs that only showed up mid-game.
The 18-useState component
Here's what the top of the Emoji React game component looked like. Have a look at how many useState calls there are.
1const ReactionSpeedGame = () => {2 const [difficulty, setDifficulty] = useState<Difficulty>(Difficulty.easy);3 const [grid, setGrid] = useState<Array<number>>(4 new Array(getAmount(difficulty, 0)).fill(0)5 );6 const [gridIndex, setGridIndex] = useState<number>(0);7 const [score, setScore] = useState<number>(0);8 const [gameIntervalID, setGameIntervalId] = useState<NodeJS.Timer>();9 const [maxGameLength, setMaxGameLength] = useState<number>(getGameLength(difficulty));10 const [currentGameLength, setCurrentGameLength] = useState<number>(0);11 const [gameRunning, setGameRunning] = useState<boolean>(false);12 const [positiveReactions, setPositiveReactions] = useState<number>(0);13 const [negativeReactions, setNegativeReactions] = useState<number>(0);14 const [name, setName] = useState<string>('me');15 const [highscoreMessage, setHightScoreMessage] = useState<string>('');16 const [scoreSubmittedSuccessfully, setScoreSubmittedSuccessfully] =17 useState<boolean>(false);18 const [error, setError] = useState<boolean>(false);19 const [highscoresData, setHighscoresData] = useState<Record<string, any[]>>({});20 const [activeHightScoreFilter, setActiveHightScoreFilter] =21 useState<Difficulty>(Difficulty.easy);22 const [loading, setLoading] = useState<boolean>(true);23 // ...24};
Eighteen π¬ I knew it was a mess while I was writing it, but I was having fun building the game and didn't care. Game state, UI state, highscore state, and timer state all mixed together in a flat list with no grouping and no indication of what belongs to what.
The real pain shows up later in resetGame. To reset the game, you have to manually zero out about twelve of those variables one by one:
1const resetGame = useCallback(() => {2 setError(false);3 setHightScoreMessage('');4 setGrid(new Array(getAmount(difficulty, windowWith)).fill(0));5 setMaxGameLength(getGameLength(difficulty));6 setCurrentGameLength(0);7 setScore(0);8 setPositiveReactions(0);9 setNegativeReactions(0);10 setName('me');11 setGameRunning(false);12 clearInterval(gameIntervalID); // stale!13 setScoreSubmittedSuccessfully(false);14}, [difficulty, windowWith, gameIntervalID]);
This works, but add a 19th variable and forget to include it in the reset and you've got a bug that only shows up mid-game with no obvious cause.
There's also a bug hiding in that snippet. See clearInterval(gameIntervalID)? That's a stale closure, covered in the timers in refs section.
Group state that changes together
The fix is pretty straightforward. Group variables by what they represent and when they change. If five variables always reset together, they belong in the same object.
1// Game state - changes during gameplay2const [gameState, setGameState] = useState({3 score: 0,4 positiveReactions: 0,5 negativeReactions: 0,6 gameRunning: false,7 currentGameLength: 0,8});910// Config state - changes when difficulty changes11const [difficulty, setDifficulty] = useState<Difficulty>(Difficulty.easy);12const maxGameLength = getGameLength(difficulty);13const grid = new Array(getAmount(difficulty, windowWidth)).fill(0);1415// Highscore UI state - changes after submission16const [highscoreState, setHighscoreState] = useState({17 name: 'me',18 message: '',19 submitted: false,20 error: false,21});2223// Highscore data - fetched once24const [highscoresData, setHighscoresData] = useState<Record<string, any[]>>({});25const [loading, setLoading] = useState(true);
A few things happened here. First, maxGameLength and grid don't need to be state at all. They're derived from difficulty, so you can just compute them. That's two fewer state variables for free.
Second, the game state and highscore UI state are now grouped into objects. You could take this further with useReducer, so you dispatch named actions instead of calling six setters:
1// With useReducer, resetGame becomes a single dispatch:2dispatch({ type: 'RESET' });34// Instead of manually zeroing out 12 variables:5setScore(0);6setPositiveReactions(0);7setNegativeReactions(0);8setGameRunning(false);9setCurrentGameLength(0);10// ... and on and on
Whether you use useReducer or just group your useState calls into objects, the point is the same: state that changes together should live together, and your reset function should be one call, not twelve.
useReducer for game state
If you haven't used useReducer before: it's like useState, but instead of calling setters directly you dispatch named actions to a reducer function. The reducer takes the current state and the action, and returns the next state. Useful when multiple state updates need to happen together.
1const [state, dispatch] = useReducer(reducer, initialState);23// reducer: a pure function that takes current state + an action,4// and returns the next state.5function reducer(state, action) {6 switch (action.type) {7 case 'CORRECT_ANSWER':8 return { ...state, score: state.score + action.points };9 case 'RESET':10 return initialState;11 default:12 return state;13 }14}1516// dispatch: call this with an action to trigger a state update.17dispatch({ type: 'CORRECT_ANSWER', points: 10 });
Grouping state into objects is a good start, but when eight different user actions all update overlapping fields, you end up with a lot of spread operators and a lot of places to forget one. At that point you probably want a reducer (or you just want to stop typing setScore for the fifth time).
Colour Match had 15 useState calls just for game state. A correct answer needed to update six of them at once:
1// Colour Match: 15 useState calls for game state alone2const [gameState, setGameState] = useState<GameState>(GameState.idle);3const [countdownValue, setCountdownValue] = useState(3);4const [score, setScore] = useState(0);5const [streak, setStreak] = useState(0);6const [bestStreak, setBestStreak] = useState(0);7const [correct, setCorrect] = useState(0);8const [wrong, setWrong] = useState(0);9const [target, setTarget] = useState<GameColor | null>(null);10const [options, setOptions] = useState<GameColor[]>([]);11const [roundTimeLeft, setRoundTimeLeft] = useState(1);12const [gameTimeLeft, setGameTimeLeft] = useState(0);13const [comboText, setComboText] = useState('');14const [flashIndex, setFlashIndex] = useState<number | null>(null);15const [flashType, setFlashType] = useState<'correct' | 'wrong' | null>(null);1617// A correct answer touches 6 of these:18setScore((s) => s + points);19setStreak((s) => {20 const newStreak = s + 1;21 setBestStreak((best) => Math.max(best, newStreak));22 return newStreak;23});24setCorrect((c) => c + 1);25setFlashIndex(index);26setFlashType('correct');27setComboText('Nice combo!');
With useReducer, each action is a named object. The reducer handles all the field updates in one place, and the component just dispatches intent:
1// The reducer type - every action is explicit2type GameAction =3 | { type: 'START_COUNTDOWN' }4 | { type: 'COUNTDOWN_TICK'; value: number }5 | { type: 'START_PLAYING'; gameDuration: number }6 | { type: 'NEW_ROUND'; target: GameColor; options: GameColor[] }7 | { type: 'TICK'; roundTimeLeft: number; gameTimeLeft: number }8 | { type: 'CORRECT_ANSWER'; points: number; index: number; comboText: string }9 | { type: 'WRONG_ANSWER'; index: number }10 | { type: 'GAME_OVER' }11 | { type: 'STOP' };1213// In the component:14const [state, dispatch] = useReducer(gameReducer, initialGameState);1516// A correct answer is now one call:17dispatch({ type: 'CORRECT_ANSWER', points, index, comboText });1819// The reducer handles the 6 state updates in one place:20case 'CORRECT_ANSWER': {21 const newStreak = state.streak + 1;22 return {23 ...state,24 score: state.score + action.points,25 streak: newStreak,26 bestStreak: Math.max(state.bestStreak, newStreak),27 correct: state.correct + 1,28 comboText: action.comboText,29 flashIndex: action.index,30 flashType: 'correct',31 };32}
The nice thing about a reducer is that it's a pure function, so you can test it without React, without the DOM, without mounting anything. Just pass in a state and an action and check the output.
I kept the highscore state as separate useState calls because highscore submission is all network IO, loading spinners, and error messages, which doesn't really belong in a game reducer. Ideally it'd be handled by something like TanStack Query, which is on my list.
That covers the state side of things. The timer bugs were worse.
Timers go in refs, not state
A ref (useRef) holds a mutable value on .current that persists across renders without triggering re-renders. Most people know refs for DOM access, but they're just as useful for storing values React doesn't need to know about, like timer IDs.
Remember that clearInterval(gameIntervalID) call in resetGame? Here's the bug.
1// The bug: interval ID stored in state2const [gameIntervalID, setGameIntervalId] = useState<NodeJS.Timer>();34const resetGame = useCallback(() => {5 // gameIntervalID is captured at the time this callback was created.6 // If the interval was set AFTER this callback was memoised,7 // we're clearing the wrong interval (or undefined).8 clearInterval(gameIntervalID);9 // ...10}, [difficulty, windowWith, gameIntervalID]);
The interval ID is stored in state. resetGame is wrapped in useCallback, which means it captures gameIntervalID at the time the callback is created. If a new interval was started after the callback was memoised, you're clearing the old ID, not the current one. The real interval keeps running.
That's a stale closure, and the fix is a ref.
1// The fix: interval ID in a ref2const gameIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);34const resetGame = useCallback(() => {5 // gameIntervalRef.current always points to the latest value.6 // No stale closure. No dependency array games.7 if (gameIntervalRef.current) clearInterval(gameIntervalRef.current);8 gameIntervalRef.current = null;9 // ...10}, [difficulty, windowWidth]);1112const playHandler = () => {13 resetGame();14 setGameRunning(true);15 gameIntervalRef.current = startGame(/* ... */);16};
Why refs work here
The difference comes down to how React treats state vs refs:
1// State is a snapshot per render:2const [intervalId, setIntervalId] = useState(null);3// When React re-renders, intervalId is whatever it was4// at the time the component rendered. Your callback5// closes over THAT value.67// Refs are mutable and shared across renders:8const intervalRef = useRef(null);9// intervalRef.current is always the latest value,10// no matter when you read it.
State gives you a frozen snapshot for each render, which is exactly what you want for values that affect the UI. But an interval ID doesn't affect the UI, you never render it. You just need to read and write it from callbacks, and you need the latest value every time. That's what refs are for.
If a value needs to be read in a callback but doesn't need to trigger a re-render, it belongs in a ref. If React doesn't need to know about it, neither does useState.
Clean up every timer you create
The Colour Match game had a different timer problem. During the countdown (3, 2, 1, go!), the interval was stored in a local variable, which sounds fine until you realise what happens next.
1const startGame = useCallback(() => {2 // ...3 let count = 3;4 // This interval is stored in a local variable.5 // If the component unmounts, or the user clicks "Stop"6 // during the countdown, nobody clears it.7 const countdownInterval = setInterval(() => {8 count--;9 if (count > 0) {10 setCountdownValue(count);11 } else {12 clearInterval(countdownInterval);13 setGameState(GameState.playing);14 // ...15 }16 }, 700);17}, [difficulty, clearTimers]);
If the user clicks "Stop" during the countdown, or the component unmounts, that interval keeps firing. Nobody has a reference to it except the local variable, which is gone. You've got a zombie timer setting state on an unmounted component.
The fix follows the same pattern. Every setInterval gets a ref, and every ref gets included in the cleanup function:
1const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);23const clearTimers = useCallback(() => {4 if (roundTimerRef.current) clearInterval(roundTimerRef.current);5 if (gameTimerRef.current) clearInterval(gameTimerRef.current);6 if (countdownTimerRef.current) clearInterval(countdownTimerRef.current);7}, []);89const startGame = useCallback(() => {10 // ...11 let count = 3;12 countdownTimerRef.current = setInterval(() => {13 count--;14 if (count > 0) {15 setCountdownValue(count);16 } else {17 if (countdownTimerRef.current) clearInterval(countdownTimerRef.current);18 countdownTimerRef.current = null;19 setGameState(GameState.playing);20 // ...21 }22 }, 700);23}, [difficulty, clearTimers]);
Then the cleanup effect handles unmount:
1// Every timer ref gets cleaned up on unmount2useEffect(() => {3 return () => clearTimers();4}, [clearTimers]);
Every setInterval (or setTimeout you might need to cancel) needs two things: a ref to store the ID, and a cleanup path that clears it. If your clearTimers function doesn't know about a timer, that timer is a leak waiting to happen.
It does feel like a lot of refs. But every timer I didn't track was a memory leak I had to debug later, so I'll take the refs.
Don't randomise in render
Emoji React shows a grid of cells where one cell is active and displays either a positive or negative emoji. Tap a positive one and you score points, tap a negative one and you lose points.
The first version decided which emoji to show inside the .map() callback during render:
1// The bug: Math.random() in render determines emoji type2{grid.map((_, index) => {3 const isActive = index === gridIndex && !gameOver;45 // Every re-render re-rolls the dice.6 // Clicking an emoji updates state, which triggers7 // a re-render. Math.random() runs again and picks8 // a different type. The emoji flips between taps.9 const isPositive = Math.random() > 0.4;10 const emoji = isPositive11 ? positiveReactions[Math.floor(Math.random() * positiveReactions.length)]12 : negativeReactions[Math.floor(Math.random() * negativeReactions.length)];1314 return (15 <button key={index} disabled={!isActive} onClick={handleGameClick}>16 {isActive && <span>{emoji.emoji}</span>}17 </button>18 );19})}
This is broken in a way that only shows up mid-game. You tap a positive emoji, score goes up, React re-renders, and Math.random() re-rolls the same cell to negative. If you double-tap (easy to do when you're rushing), the second tap registers against the re-rolled negative emoji and you lose points on what looked like the same cell.
Not gonna lie lol, when folks first played this three years ago I pitched it as a 'feature, not a bug'.
The fix: determine the emoji type once per tick in the game loop, store it in reducer state, and have GameGrid just read from state. Zero randomness at render time.
1// The fix: TICK action carries emojiType and emojiIndex from the game loop2// In the game loop (setInterval callback):3const emojiType = emojiSequenceRef.current[currentGameLength];4const emojiIndex = Math.floor(Math.random() * 5);5dispatch({ type: 'TICK', currentGameLength, gridIndex, emojiType, emojiIndex });67// The reducer stores it in state:8case 'TICK':9 return {10 ...state,11 currentGameLength: action.currentGameLength,12 gridIndex: action.gridIndex,13 activeEmojiType: action.emojiType,14 activeEmojiIndex: action.emojiIndex,15 lastClickType: null,16 };1718// GameGrid reads from state, never calls Math.random():19const emoji = isActive20 ? getEmoji(activeEmojiType, activeEmojiIndex)21 : null;
Balanced distribution
Fixing the render bug exposed a second problem. With Math.random() > 0.4 on every tick, the positive/negative split was 60/40 on average, but in practice you'd get streaks of five negatives in a row just by luck. It felt unfair.
The fix was createEmojiSequence(). It runs once when the game starts and builds a list long enough to cover every tick the game will run for. Instead of rolling the dice every tick, that list is made of shuffled batches of 5, where each batch has exactly 3 positive and 2 negative. Same idea as shuffling a deck of cards vs drawing from an infinite pile. Every batch of 5 is guaranteed to have the right ratio:
1// Pre-shuffled batches: 3 positive, 2 negative per batch of 5.2// Same idea as shuffling a deck vs drawing from an infinite pile.3export const createEmojiSequence = (length: number): EmojiType[] => {4 const sequence: EmojiType[] = [];5 const batch: EmojiType[] = [6 EmojiType.positive,7 EmojiType.positive,8 EmojiType.positive,9 EmojiType.negative,10 EmojiType.negative,11 ];1213 while (sequence.length < length) {14 const shuffled = [...batch];15 for (let i = shuffled.length - 1; i > 0; i--) {16 const j = Math.floor(Math.random() * (i + 1));17 [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];18 }19 sequence.push(...shuffled);20 }2122 return sequence.slice(0, length);23};
The game loop then just reads emojiSequenceRef.current[tick] each tick and bumps the counter. One shuffle at the top, every pick after that is already decided, and there's no randomness left at render time.
So yeah, never put Math.random() or side effects in render. Work out random values in an event handler or game loop and stick them in state. If you need a balanced distribution, pre-generate the sequence up front.
One loop, not three
Colour Match had three setInterval timers running at the same time. The countdown is the 3, 2, 1 sequence before the game starts. The game clock is the overall time remaining shown in the stats bar. And the round timer is the bar that shrinks to show how long you've got to match each colour. That's three timers to keep track of, three refs to clean up, and three chances for things to fall out of sync.
1// Colour Match before: 3 independent setInterval timers2const roundTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);3const gameTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);4const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);56const clearTimers = useCallback(() => {7 if (roundTimerRef.current) clearInterval(roundTimerRef.current);8 if (gameTimerRef.current) clearInterval(gameTimerRef.current);9 if (countdownTimerRef.current) clearInterval(countdownTimerRef.current);10}, []);1112// Countdown interval (700ms ticks)13countdownTimerRef.current = setInterval(() => { /* ... */ }, 700);1415// Game timer interval (200ms ticks)16gameTimerRef.current = setInterval(() => { /* ... */ }, 200);1718// Round timer interval (50ms ticks)19roundTimerRef.current = setInterval(() => { /* ... */ }, 50);
The problem is these intervals drift. The round timer ticks 50ms early, the game clock falls behind, and the countdown runs at its own pace. They're all supposed to agree on timing, but none of them actually talk to each other.
Three intervals drift independently. One loop derives all values from the same timestamp each frame.
I'd already solved this. The Particles game on this site is built in raw TypeScript with a canvas, a proper requestAnimationFrame loop, particle effects, collision detection, the works. I knew how game loops were supposed to work. But Colour Match and Emoji React were "just React components", so I got lazy, had too much fun playing the games themselves, and reached for setInterval three times instead of thinking about it for five minutes.
The fix is to replace all three intervals with a single requestAnimationFrame loop. rAF fires once per frame (roughly every 16ms), so you get smooth updates. And instead of each interval tracking its own counter, you derive everything from timestamps.
1// After: one requestAnimationFrame loop drives everything2const rafRef = useRef<number | null>(null);3const gameStartTimeRef = useRef(0);4const roundStartTimeRef = useRef(0);5const countdownStartTimeRef = useRef(0);67const tick = useCallback(() => {8 const phase = phaseRef.current;910 if (phase === GamePhase.countdown) {11 const elapsed = Date.now() - countdownStartTimeRef.current;12 const value = 3 - Math.floor(elapsed / 700);13 if (value > 0) {14 dispatch({ type: 'COUNTDOWN_TICK', value });15 } else {16 dispatch({ type: 'START_PLAYING', gameDuration: config.gameDuration });17 gameStartTimeRef.current = Date.now();18 phaseRef.current = GamePhase.playing;19 nextRound();20 }21 }2223 if (phase === GamePhase.playing) {24 const gameElapsed = Math.floor((Date.now() - gameStartTimeRef.current) / 1000);25 const gameTimeLeft = Math.max(0, config.gameDuration - gameElapsed);26 const roundElapsed = Date.now() - roundStartTimeRef.current;27 const roundTimeLeft = Math.max(0, 1 - roundElapsed / config.roundTime);2829 dispatch({ type: 'TICK', roundTimeLeft, gameTimeLeft });3031 if (gameTimeLeft <= 0) {32 dispatch({ type: 'GAME_OVER' });33 return; // loop stops34 }35 }3637 if (phase === GamePhase.countdown || phase === GamePhase.playing) {38 rafRef.current = requestAnimationFrame(tick);39 }40}, [config, nextRound]);4142// Cleanup is one line:43useEffect(() => {44 return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };45}, []);
Now there's one ref and one cleanup, and the loop checks what phase the game is in and derives the right values from wall-clock timestamps. If the phase isn't countdown or playing, it just stops scheduling itself.
You can play the refactored version at Colour Match in the arcade π₯
Last one.
Store timestamps, derive everything
Emoji React counted seconds by incrementing a counter inside setInterval, but intervals aren't clocks. JavaScript is single-threaded, so the callback can only fire when the main thread is free.
1// Emoji React before: counting seconds with a counter2let currentGameLength = 0;3const gameInterval = setInterval(() => {4 currentGameLength = currentGameLength + 1;5 setCurrentGameLength(currentGameLength);67 if (currentGameLength === maxGameLength) {8 clearInterval(gameInterval);9 endOfGameCallback();10 }11}, getDifficultyInterval(difficulty));
If a GC pause, a heavy render, or a layout recalculation keeps the main thread busy for 50ms, your callback queues behind it and fires late. A 1-second interval that fires 50ms late every tick drifts by nearly a full second over 20 ticks. In a timed game, that adds up fast.
The fix: store a start timestamp, then derive elapsed time from Date.now() on every tick.
1// After: derive time from timestamps2const gameStartTime = Date.now();34gameIntervalRef.current = setInterval(() => {5 const elapsed = Date.now() - gameStartTime;6 const currentGameLength = Math.floor(7 elapsed / getDifficultyInterval(difficulty)8 );910 if (currentGameLength >= maxGameLength) {11 clearInterval(gameIntervalRef.current);12 dispatch({ type: 'GAME_OVER' });13 return;14 }1516 dispatch({ type: 'TICK', currentGameLength, gridIndex });17}, getDifficultyInterval(difficulty));
Now a delayed tick doesn't lose time, it just reads the clock and catches up. The game timer stays accurate even when callbacks fire late.
Ghost round protection
There's one more timing bug worth mentioning. setTimeout callbacks can fire after the game has ended. In Colour Match, a correct answer schedules the next round 150ms later, and if the game timer hits zero in those 150ms, you get a ghost round on a dead game.
1// The ghost round bug:2// setTimeout fires 150ms later - but the game ended 50ms ago.3if (color.name === target.name) {4 setTimeout(() => nextRound(), 150);5 // If GAME_OVER fires before this timeout,6 // nextRound() runs on a dead game.7}89// Fix: check phase before acting10const nextRound = useCallback(() => {11 if (phaseRef.current !== GamePhase.playing) return;12 // ...13}, [difficulty]);
A ref that tracks the current phase lets you gate any delayed callback. If the phase has moved on, the callback exits early. No ghost rounds, no state updates on unmounted components.
Try both refactored games in the arcade: Colour Match and Emoji React π
Wrapping up
All of this came from fixing two small browser games, but none of it is really game-specific. If you've got timers, animations, or async callbacks in a component, you'll probably hit the same stuff.
The patterns I should've reached for from the start:
- Group state that changes together, and reach for a reducer once multiple actions touch overlapping fields
- Keep timer IDs in refs and clean up every interval you create
- Never put randomness or side effects in render. Work it out in the loop or event handler and store the result
- Derive time from timestamps, not tick counts
Together they turned a mess into two games I'm actually happy with.
Here's that same bare bones game from the top of the article with the fixes that actually apply at this scale: timer IDs in refs, cleanup on unmount, and timestamp-based timing instead of a tick counter. A rAF loop, a reducer, and pre-generated sequences don't earn their complexity in a 9-cell demo, but you can see all of them in action in the Colour Match and Emoji React source.
Was this useful?
Thanks for reading! Time for one more?
// Sketchy's journal β the whole DB// boils down to four tables and a// single sync expo-sqlite handle.const db = SQLite.openDatabaseSync('journal.db')
πΉ Sketchy - Skateboard Companion App
03 May 2026 β’ π 30 min readBehind the scenes of Sketchy, a skate companion app built on Expo SDK 55, React Native 0.83, expo-sqlite, OpenStreetMap and EAS, with the New Architecture and React Compiler turned on.
<PressableaccessibilityRole="button"accessibilityLabel="Submit form"onPress={handlePress}/>
π¦Teaching React Native to Talk
17 Mar 2026 β’ π 18 min read β’ Updated 18 Apr 2026What VoiceOver and TalkBack actually need from your app. Semantic roles, focus management, live regions, and the handful of props that make all the difference.