Safari Won't Play Videos via React useEffect
August 27, 2020TL;DR
View this example in Safari to see how playing a video via
useEffect
won’t work. If you’re attempting to make a video play inside a React effect, useuseLayoutEffect
.
useEffect vs useLayoutEffect
The React docs have the following tip that calls out when you should use useLayoutEffect
vs useEffect
:
Unlike
componentDidMount
orcomponentDidUpdate
, effects scheduled withuseEffect
don’t block the browser from updating the screen. This makes your app feel more responsive. The majority of effects don’t need to happen synchronously. In the uncommon cases where they do (such as measuring the layout), there is a separateuseLayoutEffect
Hook with an API identical touseEffect
.
In my experience, it’s pretty obvious when running an effect that should be a layout effect, such as the measuring example called out in the React docs. And the even though the behavior of useEffect
is slightly different in timing from the previous componentDidMount
and componentDidUpdate
APIs, this advice makes a lot of sense and it is engrained in me now. When using hooks and writing effects, I always reach for useEffect
first.
However, when developing livefromquarantine.club I found a case where it wasn’t obvious that I had to use useLayoutEffect
instead of useEffect
. I think my brain was holding on to ”useLayoutEffect
is for measuring stuff!” and not realizing there are other events that need to be synchronous.
Safari WebKit Video Policies
Safari requires a user gesture to play video on both iOS and macOS. Here’s the specific policy from that link:
A
<video>
element can use theplay()
method to automatically play without user gestures only when it contains no audio tracks or has itsmuted
property set totrue
.
I also found this WebKit blog post interesting for this section (emphasis mine):
A note about the user gesture requirement: when we say that an action must have happened “as a result of a user gesture”, we mean that the JavaScript which resulted in the call to
video.play()
, for example, must have directly resulted from a handler for atouchend
,click
,doubleclick
, orkeydown
event. So,button.addEventListener('click', () => { video.play(); })
would satisfy the user gesture requirement.video.addEventListener('canplaythrough', () => { video.play(); })
would not.
Example
So what if you try to call video.play()
using React’s effects?
View this example on Codesandbox.
const PlayVideo = () => {
const video = useRef()
const [playVideo, setPlayVideo] = useState(false)
const onClick = () => setPlayVideo(true)
// This will NOT work in Safari
useEffect(() => {
if (playVideo) video.current.play()
}, [playVideo])
// This will work in Safari
useLayoutEffect(() => {
if (playVideo) video.current.play()
}, [playVideo])
return (
<div>
<video ref={video} />
<button onClick={onClick}>Play</button>
</div>
)
}
In other browsers like Chrome, both effects will work to play the video. But in Safari, only the useLayoutEffect
will work. Even though play()
is called via the onClick
handler, Safari doesn’t see the play()
call as the direct result of the click handler.
In order to get the video to play, you need to call play()
from useLayoutEffect
or directly in the onClick
handler.
YouTube
This also partially applies to the YouTube Player API. If you try to call ytPlayer.playVideo()
inside useEffect
a similar thing will happen. On iOS, the player will not start, but on macOS both the useEffect
and useLayoutEffect
handlers will behave the same.
Here's another example using the YouTube Player API.
Conclusion
In hindsight, this seems obvious but it wasn’t when I first ran into the issue in an application. It stumped me and wasn’t something I realized until walking around the house the next day.
A few lessons that I learned from this:
- The example called out in the React docs is the most likely reason to use
useLayoutEffect
but it makes sense to learn what is happening under the hood so that cases like this are more immediately clear what’s happening. This probably applies to all APIs. - As always, reducing error cases as much as possible is very helpful. I first encountered the behavior when using the YouTube player but if I had reduced the case to using a plain
<video>
element I would’ve gotten this much nicer error from Safari:The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.
Good error messages are very important.