An <audio> element owns paused, currentTime, duration and readyState, and the browser mutates them on its own schedule — so any React state that copies those values is a duplicate waiting to disagree with the element. The correct pattern since React 18 is useSyncExternalStore: subscribe to the element's play, pause, ended and durationchange events, and have getSnapshot read audioRef.current.paused directly rather than caching a mirror of it. Deliberately leave timeupdate out of that subscription — playback position belongs in a requestAnimationFrame loop that writes to the DOM, not in React state.
Update, September 2026: The player has since been changed to do what this piece argues for: playback state is read through useSyncExternalStore rather than mirrored, play()'s promise is handled, the skip-back modulo is fixed, and the build moved to Vite and React 19. The code quoted below is the version being examined, not the current one.
I wrote the first version of this post in January 2023 against a small Create React App project called Playah! — a track list, a scrubber, transport controls. A beginner project with one genuinely hard problem in it, which I named correctly and could not solve properly, because the API that solves it did not exist for the version of React the project is pinned to. It is still on react ^17.0.1 and react-scripts 4.0.2. I have gone back and read the code rather than my memory of it, so some of what follows is me correcting myself.
The audio element runs a state machine React can't see#
React's model is that state lives in components and the DOM reflects it. <audio> breaks that. It keeps currentTime, duration, paused, ended and readyState internally, and the browser writes to them during playback, on buffering stalls, and when the user hits a hardware media key — all from outside React entirely.
The instinct is to mirror the element into useState on every tick. Here is what that actually looked like in App.js:
const timeUpdateHandler = (e) => {
const current = e.target.currentTime;
const duration = e.target.duration;
const animationPercentage = (current / duration) * 100;
setSongInfo({ ...songInfo, currentTime: current, duration, animationPercentage });
};Two things are wrong and only one is obvious. The obvious one is the re-render cost. The subtle one is { ...songInfo, ... } — spreading a value captured by the closure, so any other update to songInfo between renders is silently clobbered. The functional form, setSongInfo(prev => ({ ...prev, ... })), is not a style preference; it is the difference between correct and usually correct.
While I am correcting things: the original post said timeupdate fires "sixty times a second". It does not. The HTML spec caps it at roughly 4Hz to 66Hz, and browsers in practice sit near the bottom of that range — about every 250ms — varying with system load. That cuts both ways. Four updates a second is cheap enough that the re-render cost is survivable, and far too coarse for a scrubber meant to glide. The 60fps problem only arrives when you reach for requestAnimationFrame to fix the jank, which is exactly what people do.
Why does play() reject, and what are the real error names?#
play() returns a Promise. The version in src/util.js handles it like this:
export const playAudio = (isPlaying, audioRef) => {
if (isPlaying) {
const playPromise = audioRef.current.play();
if (playPromise !== undefined) {
playPromise.then((audio) => {
audioRef.current.play();
});
}
}
};There is no .catch(), so every rejection surfaces as an unhandled promise rejection in the console. And the .then() calls play() a second time on an element that has, by definition, just started playing. I wrote that. It is the shape of code you get when you know a promise is involved and have not worked out what it is promising. Worse, playAudio(isPlaying, audioRef) is invoked in the body of LibrarySong — an imperative media call running during render, once per track in the library, on every pass. React never promised that render is called once.
The three rejections worth naming, because they need different handling:
NotAllowedError— the browser or OS refuses playback in this context. Chrome gates unmuted autoplay behind its Media Engagement Index and a user gesture; Safari is stricter still. This is not a bug to retry. It is a signal to render a play button and wait for a real click.AbortError— "The play() request was interrupted by a call to pause()." Apause()landed before theplay()promise settled. Rapid clicking on a play/pause toggle produces this reliably, as does switchingsrcmid-load.NotSupportedError— the source is not a format the browser can decode. Terminal; move to the next track or surface an error.
The general lesson holds and I would still lead with it: when your UI state and a device's state can drift, the device wins. Set your flag from the event that confirms the change, not from the call that requested it. It is the same discipline as driving someone else's web app with Playwright — you never trust that your click did the thing, you wait for the system to tell you it did.
useSyncExternalStore is the hook this problem was waiting for#
Here is the satisfying part. In March 2022, React 18.0 shipped useSyncExternalStore, an API whose entire purpose is subscribing to an external mutable source and reading it without tearing under concurrent rendering. That is a one-sentence description of the audio problem. The signature is useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?), and applied to a media element it looks like this:
import { useCallback, useRef, useSyncExternalStore } from 'react';
// Deliberately no 'timeupdate' — see the next section.
const MEDIA_EVENTS = [
'play', 'pause', 'ended', 'waiting', 'playing',
'durationchange', 'ratechange', 'volumechange', 'emptied', 'error',
];
export function useAudioStatus(audioRef) {
const cache = useRef({ paused: true, ended: false, duration: 0 });
const subscribe = useCallback((onStoreChange) => {
const el = audioRef.current;
if (!el) return () => {};
for (const type of MEDIA_EVENTS) el.addEventListener(type, onStoreChange);
return () => {
for (const type of MEDIA_EVENTS) el.removeEventListener(type, onStoreChange);
};
}, [audioRef]);
const getSnapshot = useCallback(() => {
const el = audioRef.current;
const paused = el ? el.paused : true;
const ended = el ? el.ended : false;
const duration = el && Number.isFinite(el.duration) ? el.duration : 0;
const prev = cache.current;
if (prev.paused === paused && prev.ended === ended && prev.duration === duration) {
return prev; // same identity, so React does not re-render
}
cache.current = { paused, ended, duration };
return cache.current;
}, [audioRef]);
return useSyncExternalStore(subscribe, getSnapshot, () => cache.current);
}The caching in getSnapshot is the part people get wrong, and React will tell you so: return a freshly built object each call and you get "The result of getSnapshot should be cached to avoid an infinite loop". React compares snapshots with Object.is, so a new object literal is always a new value, forever. Either return a primitive, or hold the last snapshot and only rebuild it when a field genuinely changed.
Two caveats before you paste this. subscribe runs after commit, so audioRef.current is populated by then — but if the <audio> element is conditionally rendered or remounted, the ref changing does not re-run subscribe; hold the element in state via a callback ref if that is your situation. And subscribe must be stable, or React resubscribes on every render.
What you get is a component that reads const { paused, duration } = useAudioStatus(audioRef) and is structurally incapable of disagreeing with the element, because it does not store a copy. There is no setIsPlaying(!isPlaying) left to get out of step. The ecosystem landed in the same place — react-use-audio-player rewrote its synchronisation onto useSyncExternalStore for exactly this reason. My 2023 thesis was that the framework had no answer for imperative resources. It turned out React had already shipped one.
How do you show playback position without re-rendering 60 times a second?#
Put currentTime in state and every frame of playback re-renders the tree. In Playah! that was worse than usual, because Song.js derives the album-art rotation from the same state object:
const rotateImg = {
transform: `rotate(${(songInfo.animationPercentage * 360 / 100)}deg)`,
};Spinning artwork driven through React's reconciler. It works at 4Hz and visibly stutters, and the fix people reach for — a requestAnimationFrame loop calling setState — trades a stutter for sixty renders a second.
Position is a continuous value that only ever paints. It should never enter React state at all. Read it in a rAF loop and write it to the DOM as a CSS custom property:
useEffect(() => {
const audio = audioRef.current;
const track = trackRef.current;
if (!audio || !track) return;
let frame = 0;
let lastSecond = -1;
const paint = () => {
const { currentTime, duration } = audio;
track.style.setProperty('--progress', duration > 0 ? currentTime / duration : 0);
const second = Math.floor(currentTime);
if (second !== lastSecond) {
lastSecond = second;
labelRef.current.textContent = formatTime(second);
}
};
const tick = () => { paint(); frame = requestAnimationFrame(tick); };
const start = () => { if (!frame) frame = requestAnimationFrame(tick); };
const stop = () => { cancelAnimationFrame(frame); frame = 0; paint(); };
audio.addEventListener('play', start);
audio.addEventListener('pause', stop);
audio.addEventListener('ended', stop);
audio.addEventListener('seeked', paint);
paint();
return () => {
cancelAnimationFrame(frame);
audio.removeEventListener('play', start);
audio.removeEventListener('pause', stop);
audio.removeEventListener('ended', stop);
audio.removeEventListener('seeked', paint);
};
}, []);.track__fill {
transform: scaleX(var(--progress, 0));
transform-origin: left center;
}Zero re-renders during playback. The fill is a compositor-only transform, so it does not even trigger layout. The time label updates once per second, on one text node. The loop only runs while audio is actually playing, and the browser pauses requestAnimationFrame in background tabs for free.
This also removes a class of bug I had shipped. The scrubber was a controlled <input type="range" value={songInfo.currentTime}> whose onChange wrote e.target.value — a string — into both audio.currentTime and React state, where a later Math.floor(time / 60) coerced it back. It worked by accident. Uncontrolled input, ref, and a direct DOM write has fewer moving parts and no coercion happening off-screen.
It is the same conclusion I reached building an e-commerce site with no framework at all, from the opposite direction: there, the DOM was the only source of truth and I wanted a component model; here, the component model wanted to own something the DOM already owned. The skill in both is knowing which layer a given fact lives on — much like rewriting a markdown tree rather than regexing the HTML it produced.
Skipping tracks: why (i - 1 + n) % n and not (i - 1) % n#
Forward is easy. Backward is where the off-by-one lives, and I want to be accurate about what the repo does, because the snippet I published in 2023 was tidier than the code:
if (direction === "skip-forward") {
await setCurrentSong(songs[(currentIndex + 1) % songs.length]);
} else {
if ((currentIndex - 1) % songs.length === -1) {
setCurrentSong(songs[songs.length - 1]);
} else {
setCurrentSong(songs[(currentIndex - 1) % songs.length]);
}
}I did handle the wrap — by special-casing it. That branch exists because JavaScript's % is a remainder operator, not a modulo one: it takes the sign of the dividend, so -1 % 5 is -1, not 4. And songs[-1] is undefined, which crashes the next render rather than wrapping to the end of the list.
The arithmetic form deletes the branch entirely:
const next = (i, n) => (i + 1) % n;
const prev = (i, n) => (i - 1 + n) % n;Adding n before taking the remainder guarantees a non-negative dividend, so the remainder is the modulo. Every wrap-around bug in carousels, pagination and track lists is this, and a special case that handles -1 will not save you the day someone skips back twice from index 0.
The await setCurrentSong(...) in that first branch is also noise. setState returns undefined; awaiting it yields a microtask tick, not a committed render. If code after the call needs the new track, it belongs in an effect keyed on the track — or better, in the ended/play handlers, where the element tells you what actually happened.
Media Session API: the lock-screen controls tutorials skip#
Almost every "build an audio player in React" article stops at the play button. None of them make the player work from a locked phone screen, which is where people actually control music. The Media Session API is about twenty lines:
useEffect(() => {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata = new MediaMetadata({
title: currentSong.name,
artist: currentSong.artist,
artwork: [{ src: currentSong.cover, sizes: '512x512', type: 'image/png' }],
});
navigator.mediaSession.setActionHandler('play', () => audioRef.current.play());
navigator.mediaSession.setActionHandler('pause', () => audioRef.current.pause());
navigator.mediaSession.setActionHandler('previoustrack', () => skip(-1));
navigator.mediaSession.setActionHandler('nexttrack', () => skip(1));
navigator.mediaSession.setActionHandler('seekto', (e) => {
if (e.seekTime != null) audioRef.current.currentTime = e.seekTime;
});
}, [currentSong]);Two details that are easy to miss. Set navigator.mediaSession.playbackState to 'playing' or 'paused' from the element's own play/pause events, not from your click handler — same rule as everywhere else in this post. And call setPositionState({ duration, position, playbackRate }) after seeks so the OS scrubber agrees with yours; it throws a TypeError if position exceeds duration, which is easy to hit while metadata is still loading and duration is NaN.
Support is not Baseline — feature-detect with 'mediaSession' in navigator rather than assuming — but where it works you get lock-screen artwork, hardware media keys and Bluetooth headset buttons for free.
What React 19 changed here, and what it didn't#
React 19.2.8 is the current stable release. Two things in the 19 line touch this code directly.
Ref callbacks can return a cleanup function (React 19.0, December 2024). Media listeners can now live on the element itself rather than in a useEffect that has to re-derive the node:
<audio
src={currentSong.audio}
ref={(el) => {
if (!el) return;
const onEnded = () => skip(1);
el.addEventListener('ended', onEnded);
return () => el.removeEventListener('ended', onEnded);
}}
/>The gotcha: because a returned value is now meaningful, TypeScript rejects ref callbacks that return anything else. The very common ref={(el) => (audioRef.current = el)} — an arrow with an implicit return — is now a type error. Add braces.
ref is an ordinary prop for function components, so a <Player ref={audioRef}> no longer needs forwardRef. And React 19.2 (October 2025) added useEffectEvent, which is genuinely useful here: it lets a media event handler read the latest currentSong without listing it as a dependency and tearing down every listener on each track change.
What did not change: <audio> is still a plain host element. React 19's custom-element property support does not apply to it, there is no <Media> primitive, and nothing in the framework knows what readyState means. The contract is unchanged — hold a ref, subscribe to events, and read through useSyncExternalStore. React gave you a better subscription primitive, not an abstraction over media.
Sass partials as a component boundary#
One partial per component — _song.scss, _player.scss, _library.scss, _nav.scss — all imported into app.scss. Not scoped; global styles with a naming convention, where the file layout mirrors the component layout.
I still think that is the right call at this size, and I would still not reach for CSS Modules here. The convention gives you none of the guarantees and none of the build cost, and the failure mode — a selector in _player.scss quietly restyling something in _library.scss — is one you can hold in your head across four components. At ten times the size it would not be. That is the same test I apply when choosing a markdown pipeline: pick the tool for the feature you will actually use, not the one you might.
What the project actually taught#
Not React. The lesson was about imperative resources inside a declarative framework — audio elements, canvases, WebGL contexts, MediaStreams, third-party map widgets. Anything with internal state the browser or a library mutates on its own schedule.
The pattern is the same every time. Hold a ref. Subscribe to the events the resource emits. Read through a snapshot rather than caching a mirror, so there is only ever one source of truth for each fact — the same discipline that stops a database growing two columns that disagree about the same offence. Never assume your call caused the change.
Get that wrong with audio and a play button lies. Get it wrong with a WebGL context and you leak GPU memory. The satisfying part of updating this post is that in 2023 I could describe the shape of the right answer but had to hand-roll it badly. The shape was right. React just took until version 18 to ship the tool.
Common questions#
Should I use useSyncExternalStore for an audio element, or is useState with event handlers fine?#
useState plus event handlers works and is what most tutorials show. useSyncExternalStore is better because it removes the copy: getSnapshot reads the element directly, so your UI cannot hold a value the element has already changed, and it is tear-safe under concurrent rendering when multiple components read the same element. The migration is small, so I would default to it.
Why does my React audio player throw "The play() request was interrupted by a call to pause()"?#
That is an AbortError from the promise play() returns. It means pause() ran, or src changed, before the play promise settled — most often from double-clicking a play/pause toggle. Always attach a .catch() (or await in a try), and drive your playing indicator from the element's play and pause events rather than from the click handler.
How do I stop my player re-rendering on every timeupdate?#
Do not put currentTime in React state. Read it in a requestAnimationFrame loop that runs only while playing, and write the result straight to the DOM — a CSS custom property feeding a transform: scaleX() for the progress fill, and textContent for the time label, updated only when the whole second changes. That gives you smooth 60fps motion with zero renders.
Is (i - 1) % n safe for wrapping backwards through a playlist?#
No. JavaScript's % is a remainder operator that takes the sign of the dividend, so at index 0 it returns -1 and songs[-1] is undefined. Use (i - 1 + n) % n, which keeps the dividend non-negative. Forward wrapping with (i + 1) % n is safe as written.
Does React 19 add anything specific for media elements?#
No media-specific API. <audio> remains a plain host element with no framework-level abstraction. What React 19 does give you is ref callbacks that return a cleanup function — so listeners can be attached in the ref rather than a separate effect — ref as a normal prop instead of forwardRef, and useEffectEvent in 19.2 for handlers that need fresh props without re-subscribing.