I'm developing a react component that is intended to become an npm package so that it can be imported into various other react projects. There seems to be a problem with using the "useRef" hook.
This is my package.json
:
{
"name": "@mperudirectio/react-player",
"publishConfig": {
"registry": "https://registry.npmjs.org/"
},
"repository": {
"type": "git",
"url": "https://github.com/mperudirectio/react-player"
},
"version": "0.0.1",
"author": "matt p",
"license": "MIT",
"scripts": {
"rollup": "rollup -c"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^24.0.1",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-typescript": "^11.0.0",
"@types/react": "^18.0.28",
"react": "^18.2.0",
"rollup": "^3.19.1",
"rollup-plugin-asset": "^1.1.1",
"rollup-plugin-dts": "^5.2.0",
"rollup-plugin-import-css": "^3.2.1",
"tslib": "^2.5.0",
"typescript": "^4.9.5"
},
"peerDependencies": {
"react": "^18.2.0"
},
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"files": [
"dist"
],
"types": "dist/index.d.ts",
"dependencies": {}
}
This is my component:
import React, { ChangeEvent, FC, useRef, useState, useEffect } from 'react';
import styles from './Player.css' assert { type: 'css' };
document.adoptedStyleSheets = [styles];
// CUSTOM HOOK
const useVideoPlayer = (videoElement: React.MutableRefObject<null | HTMLVideoElement>) => {
const [playerState, setPlayerState] = useState({
isPlaying: false,
progress: 0,
speed: 1,
isMuted: false,
});
const togglePlay = () => {
setPlayerState({
...playerState,
isPlaying: !playerState.isPlaying,
});
};
useEffect(() => {
playerState.isPlaying
? videoElement.current?.play()
: videoElement.current?.pause();
}, [playerState.isPlaying, videoElement]);
const handleOnTimeUpdate = () => {
const progress = ((videoElement.current?.currentTime ?? 1) / (videoElement.current?.duration ?? 1)) * 100;
setPlayerState({
...playerState,
progress,
});
};
const handleVideoProgress = (event: ChangeEvent<HTMLInputElement>) => {
const manualChange = Number(event.target.value);
if (videoElement.current) {
videoElement.current.currentTime = ((videoElement.current?.duration ?? 0) / 100) * manualChange;
setPlayerState({
...playerState,
progress: manualChange,
});
}
};
const handleVideoSpeed = (event: ChangeEvent<HTMLSelectElement>) => {
const speed = Number(event.target.value);
if (videoElement.current) {
videoElement.current.playbackRate = speed;
setPlayerState({
...playerState,
speed,
});
}
};
const toggleMute = () => {
setPlayerState({
...playerState,
isMuted: !playerState.isMuted,
});
};
useEffect(() => {
if (videoElement.current) {
playerState.isMuted
? (videoElement.current.muted = true)
: (videoElement.current.muted = false);
}
}, [playerState.isMuted, videoElement]);
return {
playerState,
togglePlay,
handleOnTimeUpdate,
handleVideoProgress,
handleVideoSpeed,
toggleMute,
};
};
// import video from "./assets/video.mp4";
const v = "https://file-examples.com/storage/fef1706276640fa2f99a5a4/2017/04/file_example_MP4_1280_10MG.mp4";
// MAIN COMPONENT THAT USES CUSTOM HOOK + USEREF
const App: FC = () => {
const videoElement = useRef<null | HTMLVideoElement>(null);
const {
playerState,
togglePlay,
handleOnTimeUpdate,
handleVideoProgress,
handleVideoSpeed,
toggleMute,
} = useVideoPlayer(videoElement);
return (
<div className="container">
<div className="video-wrapper">
<video
src={v}
ref={videoElement}
onTimeUpdate={handleOnTimeUpdate}
/>
<div className="controls">
<div className="actions">
<button onClick={togglePlay}>
{!playerState.isPlaying ? (
<i className="bx bx-play"></i>
) : (
<i className="bx bx-pause"></i>
)}
</button>
</div>
<input
type="range"
min="0"
max="100"
value={playerState.progress}
onChange={(e: ChangeEvent<HTMLInputElement>) => handleVideoProgress(e)}
/>
<select
className="velocity"
value={playerState.speed}
onChange={(e: ChangeEvent<HTMLSelectElement>) => handleVideoSpeed(e)}
>
<option value="0.50">0.50x</option>
<option value="1">1x</option>
<option value="1.25">1.25x</option>
<option value="2">2x</option>
</select>
<button className="mute-btn" onClick={toggleMute}>
{!playerState.isMuted ? (
<i className="bx bxs-volume-full"></i>
) : (
<i className="bx bxs-volume-mute"></i>
)}
</button>
</div>
</div>
</div>
);
};
export default App;
I don't have any problems in bundling or publishing the package on the npm registry, everything goes fine. But when I import my component the application "explodes" at runtime:
Warning: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
- You might have mismatching versions of React and the renderer (such as React DOM)
- You might be breaking the Rules of Hooks
- You might have more than one copy of React in the same app See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.
and
Uncaught TypeError: Cannot read properties of null (reading 'useRef') at Object.useRef (react.development.js:1630:1) at App (Player.tsx:87:1) at renderWithHooks (react-dom.development.js:16305:1) at mountIndeterminateComponent (react-dom.development.js:20074:1) at beginWork (react-dom.development.js:21587:1) at HTMLUnknownElement.callCallback (react-dom.development.js:4164:1) at Object.invokeGuardedCallbackDev (react-dom.development.js:4213:1) at invokeGuardedCallback (react-dom.development.js:4277:1) at beginWork$1 (react-dom.development.js:27451:1) at performUnitOfWork (react-dom.development.js:26557:1)
The problem is that if i take this exact code and write it directly into the application that is supposed to import my package, it works perfectly. After some tests I realized that the ref is not working well, like via package it never manages to fill the current
param of the ref
. But I can't figure out what the issues/differences are between the package and the "pure" code directly in my app.
Can anyone give me some help? Thank you!
UPDATE 1
converting the component from functional component
to class purecomponent
, reproducing exactly the same behavior, lifecycle and using the callback ref
everything works.
So it definitely wasn't a problem of different React
versions between package and host etc. Probably handling ref
via hooks
isn't as stable as in a class component
.
This is the new componenet code:
import React, { ChangeEvent } from 'react';
import styles from './Player.css' assert { type: 'css' };
document.adoptedStyleSheets = [styles];
const v = "https://file-examples.com/storage/fef1706276640fa2f99a5a4/2017/04/file_example_MP4_1280_10MG.mp4";
type PlayerPropz = {
ComponentName?: string
};
type PlayerState = {
isPlaying: boolean,
progress: number,
speed: number,
isMuted: boolean,
};
class Player extends React.PureComponent<PlayerPropz, PlayerState> {
declare private textInput: HTMLVideoElement | null;
declare private setTextInputRef: (element: HTMLVideoElement) => void;
declare public static defaultProps: PlayerPropz;
constructor(props: PlayerPropz) {
super(props);
this.state = {
isPlaying: false,
progress: 0,
speed: 1,
isMuted: false,
}
this.textInput = null;
this.setTextInputRef = (element: HTMLVideoElement) => {
this.textInput = element;
}
this.togglePlay = this.togglePlay.bind(this);
this.handleOnTimeUpdate = this.handleOnTimeUpdate.bind(this);
this.handleVideoProgress = this.handleVideoProgress.bind(this);
this.handleVideoSpeed = this.handleVideoSpeed.bind(this);
this.toggleMute = this.toggleMute.bind(this);
}
componentDidMount(): void {
const { ...state } = this.state;
if (this.textInput) {
state.isPlaying
? this.textInput.play()
: this.textInput.pause();
state.isMuted
? (this.textInput.muted = true)
: (this.textInput.muted = false);
}
}
componentDidUpdate(prevProps: Readonly<PlayerPropz>, prevState: Readonly<PlayerState>, snapshot?: any): void {
const { ...state } = this.state;
if (this.textInput && state.isPlaying != prevState.isPlaying) {
state.isPlaying
? this.textInput.play()
: this.textInput.pause();
}
if (this.textInput && state.isMuted != prevState.isMuted) {
state.isMuted
? (this.textInput.muted = true)
: (this.textInput.muted = false);
}
}
togglePlay() {
this.setState({ isPlaying: !this.state.isPlaying });
}
handleOnTimeUpdate() {
if (this.textInput) {
const progress = (this.textInput.currentTime / this.textInput.duration) * 100;
this.setState({ progress });
}
};
handleVideoProgress(event: ChangeEvent<HTMLInputElement>) {
if (this.textInput) {
const manualChange = Number(event.target.value);
this.textInput.currentTime = (this.textInput.duration / 100) * manualChange;
this.setState({ progress: manualChange });
}
};
handleVideoSpeed(event: ChangeEvent<HTMLSelectElement>) {
const speed = Number(event.target.value);
if (this.textInput) {
this.textInput.playbackRate = speed;
this.setState({ speed });
}
};
toggleMute() {
this.setState({ isMuted: !this.state.isMuted });
};
render() {
const { ...state } = this.state;
return (
<div className="container">
<div className="video-wrapper">
<video
src={v}
ref={(element: HTMLVideoElement) => this.setTextInputRef(element)}
onTimeUpdate={() => this.handleOnTimeUpdate()}
/>
<div className="controls">
<div className="actions">
<button onClick={() => this.togglePlay()}>
{!state.isPlaying ? (
<i className="bx bx-play"></i>
) : (
<i className="bx bx-pause"></i>
)}
</button>
</div>
<input
type="range"
min="0"
max="100"
value={state.progress}
onChange={(e: ChangeEvent<HTMLInputElement>) => this.handleVideoProgress(e)}
/>
<select
className="velocity"
value={state.speed}
onChange={(e: ChangeEvent<HTMLSelectElement>) => this.handleVideoSpeed(e)}
>
<option value="0.50">0.50x</option>
<option value="1">1x</option>
<option value="1.25">1.25x</option>
<option value="2">2x</option>
</select>
<button className="mute-btn" onClick={() => this.toggleMute()}>
{!state.isMuted ? (
<i className="bx bxs-volume-full"></i>
) : (
<i className="bx bxs-volume-mute"></i>
)}
</button>
</div>
</div>
</div>
);
}
};
Player.defaultProps = {
ComponentName: 'Player'
};
export default Player;
UPDATE 2
Changing the structure and managing the callback ref
directly from within my custom hook
as @LindaPaiste suggested, now the problem has been solved "in production": if I publish my package in the registry via npm publish
, when I download the package from my host application the component works , it keeps giving me the hooks error but only locally now.
NOTICE: To make it works locally too, you have to link (yarn link
/npm link
) both your package and the peer dependencies of your package in the host application, in my case i had to link just the react
package in addition to mine.
Player component code:
import React, { FC, ChangeEvent } from 'react';
import useVideoPlayer from '../../hooks/useVideoPlayer';
import styles from './Player.css' assert { type: 'css' };
document.adoptedStyleSheets = [styles];
const v = "https://file-examples.com/storage/fef1706276640fa2f99a5a4/2017/04/file_example_MP4_1280_10MG.mp4";
const Player: FC = () => {
const {
playerState,
togglePlay,
handleOnTimeUpdate,
handleVideoProgress,
handleVideoSpeed,
toggleMute,
ref
} = useVideoPlayer();
return (
<div className="container">
<div className="video-wrapper">
<video
src={v}
ref={ref}
onTimeUpdate={handleOnTimeUpdate}
/>
<div className="controls">
<div className="actions">
<button onClick={togglePlay}>
{!playerState.isPlaying ? (
<i className="bx bx-play"></i>
) : (
<i className="bx bx-pause"></i>
)}
</button>
</div>
<input
type="range"
min="0"
max="100"
value={playerState.progress}
onChange={(e: ChangeEvent<HTMLInputElement>) => handleVideoProgress(e)}
/>
<select
className="velocity"
value={playerState.speed}
onChange={(e: ChangeEvent<HTMLSelectElement>) => handleVideoSpeed(e)}
>
<option value="0.50">0.50x</option>
<option value="1">1x</option>
<option value="1.25">1.25x</option>
<option value="2">2x</option>
</select>
<button className="mute-btn" onClick={toggleMute}>
{!playerState.isMuted ? (
<i className="bx bxs-volume-full"></i>
) : (
<i className="bx bxs-volume-mute"></i>
)}
</button>
</div>
</div>
</div>
);
};
export default Player;
Custom hook code:
import React, { useState, useRef, useEffect, ChangeEvent } from 'react';
const useVideoPlayer = () => {
const [playerState, setPlayerState] = useState({
isPlaying: false,
progress: 0,
speed: 1,
isMuted: false,
});
const videoElement = useRef<HTMLVideoElement | null>(null);
const videoCallbackRef: React.RefCallback<HTMLVideoElement> = (element: HTMLVideoElement | null) => {
if (element) {
console.log('executed because the HTML video element was set.');
videoElement.current = element;
}
}
useEffect(() => {
if (videoElement.current) {
console.log('executed because playerState.isPlaying changed.');
playerState.isPlaying
? videoElement.current.play()
: videoElement.current.pause();
}
}, [playerState.isPlaying]);
const togglePlay = () => {
setPlayerState({
...playerState,
isPlaying: !playerState.isPlaying,
});
};
const handleOnTimeUpdate = () => {
const progress = ((videoElement.current?.currentTime ?? 1) / (videoElement.current?.duration ?? 1)) * 100;
setPlayerState({
...playerState,
progress,
});
};
const handleVideoProgress = (event: ChangeEvent<HTMLInputElement>) => {
const manualChange = Number(event.target.value);
if (videoElement.current) {
videoElement.current.currentTime = ((videoElement.current?.duration ?? 0) / 100) * manualChange;
setPlayerState({
...playerState,
progress: manualChange,
});
}
};
const handleVideoSpeed = (event: ChangeEvent<HTMLSelectElement>) => {
const speed = Number(event.target.value);
if (videoElement.current) {
videoElement.current.playbackRate = speed;
setPlayerState({
...playerState,
speed,
});
}
};
const toggleMute = () => {
setPlayerState({
...playerState,
isMuted: !playerState.isMuted,
});
};
useEffect(() => {
console.log('executed because playerState.isMuted changed.');
if (videoElement.current) {
playerState.isMuted
? (videoElement.current.muted = true)
: (videoElement.current.muted = false);
}
}, [playerState.isMuted]);
return {
playerState,
togglePlay,
handleOnTimeUpdate,
handleVideoProgress,
handleVideoSpeed,
toggleMute,
ref: videoCallbackRef
};
};
export default useVideoPlayer;
rollup
config file:
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';
import css from 'rollup-plugin-import-css';
import cleanup from 'rollup-plugin-cleanup';
import dts from 'rollup-plugin-dts';
import packageJson from './package.json' assert { type: 'json' };
export default [
{
input: 'src/index.ts',
external: ['react'],
output: [
{
file: packageJson.main,
format: 'cjs',
sourcemap: true,
assetFileNames: "assets/[name]-[hash][extname]"
},
{
file: packageJson.module,
format: 'esm',
sourcemap: true,
assetFileNames: "assets/[name]-[hash][extname]"
},
],
plugins: [
resolve(),
commonjs(),
typescript({ tsconfig: './tsconfig.json' }),
css({ modules: true }),
cleanup({ extensions: ['ts', 'tsx', 'js', 'jsx', 'mjs'] })
],
},
{
input: 'dist/esm/types/index.d.ts',
output: [{ file: 'dist/index.d.ts', format: 'esm', sourcemap: true }],
plugins: [dts()],
},
];
package.json
:
{
"name": "@mperudirectio/react-player",
"publishConfig": {
"registry": "https://registry.npmjs.org/"
},
"repository": {
"type": "git",
"url": "https://github.com/mperudirectio/react-player"
},
"version": "0.0.6",
"author": "matt p",
"license": "MIT",
"scripts": {
"rollup": "rollup -c"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^24.0.1",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-typescript": "^11.0.0",
"@types/react": "^18.0.28",
"react": "^18.2.0",
"rollup": "^3.19.1",
"rollup-plugin-asset": "^1.1.1",
"rollup-plugin-cleanup": "^3.2.1",
"rollup-plugin-dts": "^5.2.0",
"rollup-plugin-import-css": "^3.2.1",
"tslib": "^2.5.0",
"typescript": "^4.9.5"
},
"peerDependencies": {
"react": "^18.2.0"
},
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"files": [
"dist"
],
"types": "dist/index.d.ts",
"dependencies": {}
}