skip to Main Content

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:

  1. You might have mismatching versions of React and the renderer (such as React DOM)
  2. You might be breaking the Rules of Hooks
  3. 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": {}
}

2

Answers


  1. Chosen as BEST ANSWER

    "Solved"

    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;
    

  2. Most likely this is is issue related to your build process/react version mismatch.

    Host app and package should use the same react package. You can try
    npm ls react and npm ls react-dom. And you should check that you have the one and only instance of react and react-dom package.

    If you have different versions you need to fix it. Make sure that react is not bundled inside your library package and that dependencies have compatible versions. You can specify react as peer dependency.

    Edit:

    Once I’ve installed your package and checked, you are indeed have react bundled into your code: enter image description here

    You need to change your rollup config such that react and react-dom are
    external

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search