How to make a React component from a native component

Sometimes you need to embed a third-party native component into your React application, which does not work with React and often turns out to be imperative.

Such a component must be initialized, destroyed each time, and, most importantly, its state must be checked before calling its methods.

In this article I want to go through step by step how to turn such a component into declarative React component.

An example of using an imperative component

Suppose there is a video player in the form of a class with the usual methods for players:

  • play(), stop(), pause() – control playback.

  • setSource(“nice-cats.mp4”) — set the video to play.

  • applyElement(divRef) — embed the player into the desired DOM element.

In addition, the user can start/stop playback by simply clicking on the video in the player.

Our goal: to embed the player into our React application and programmatically control the playback.

If you do it head on, it will turn out something like this:

import { useEffect, useRef } from "react";
import { Player } from "video-player";

const SOURCES_MOCK = "nice-cats.mp4";

export default function App() {
  const playerElem = useRef<HTMLDivElement>(null);
  const player = useRef<Player>();

  useEffect(() => {
    player.current = new Player();
    player.current.applyElement(playerElem.current);
    player.current.setSource(SOURCES_MOCK);
    return () => player.current?.destroy();
  }, []);

  return (
    <div className="App">
      <button
        disabled={player.current?.getState().status === "playing"}
        onClick={() => player.current?.play()}
      >
        Play
      </button>
      <button
        disabled={player.current?.getState().status === "stopped"}
        onClick={() => player.current?.stop()}
      >
        Stop
      </button>
      <div ref={playerElem} />
    </div>
  );
}

For simplicity, SOURCE_MOCK is hardcoded here.

The basic principles are here:

  1. Since useEffect's second argument is an empty array, its callback will be called once when mounting the component, so we use it to initialize the player.

  2. The function returned from the useEffect callback will be called when the component is unmounted. Therefore, here you need to remember to destroy the player in order to free up the resources it occupies.

  3. The playerElem link will be filled after the corresponding div is rendered, before calling the useEffect callback. Therefore, it is possible to call applyElement without checking that the link is already ready.

Update parent component when child component changes

We notice that when the application starts, and also if the user stopped playing by clicking on the video rather than clicking on our “Stop” button, then our buttons do not know about this and are not disabled accordingly.

This happens because when clicked we call the player methods, but do not change the state of the App component. Therefore, there is no re-rendering and the buttons are not updated.

But the player has a standard way to subscribe to events:

export default function App() {
  const playerElem = useRef<HTMLDivElement>(null);
  const player = useRef<Player>();
  const [, forceUpdate] = useReducer((x) => x + 1, 0); // новое

  useEffect(() => {
    player.current = new Player();
    player.current.applyElement(playerElem.current);
    player.current.setSource(SOURCES_MOCK);
    player.current.addListener("statusChange", forceUpdate); // новое
    return () => player.current?.destroy();
  }, []);
...

We have added a subscription to the status change event. It is not necessary to unsubscribe from the event, because we return from useEffect a call to the destroy() method, which will run when the component is unmounted and will automatically unsubscribe the player from all events.

forceUpdate is a crutch function (see. React FAQ) to re-render the App, and our buttons learn about the new state of the player.

This approach has a plus:

But this is not React-way. It's common practice in React to make controlled components.

Making the player controllable

While it's good to have a single source of truth, it's even better when the state of all child components is derived from the state of the parent component. Then it definitely won't be race.

Those. Now the state of the buttons is derived directly from the state of the child component – the player. And now we invert the data flow: the state of both the buttons and the player will be derived from the App state, and not vice versa.

Therefore, components are made controllable: the parameters of the child component that interest us are, as it were, copied into the state (useState) of the parent component. And then, the parent component “knows” what properties the child should be rendered with.

As a bonus, in React we get automatic re-rendering of the parent component, in particular, updating buttons. Which is ultimately what we need.

Let's make the player more controllable so that changes in its state are reflected in changes in the App state:

export default function App() {
  const playerElem = useRef<HTMLDivElement>(null);
  const player = useRef<Player>();
  const [status, setStatus] = useState("stopped"); // новое

  useEffect(() => {
    player.current = new Player();
    player.current.applyElement(playerElem.current);
    player.current.setSource(SOURCES_MOCK);
    player.current.addListener("statusChange", setStatus); // новое
    return () => player.current?.destroy();
  }, []);

  return (
    <div className="App">
      <button
        disabled={status === "playing"} // новое
        onClick={() => player.current?.play()}
      >
        Play
      </button>
      <button
        disabled={status === "stopped"} // новое
        onClick={() => player.current?.stop()}
      >
        Stop
      </button>
      <div ref={playerElem} />
    </div>
  );
}

Now, instead of the crutch forceUpdate, there is a normal status setting. The code has become cleaner, and we are one step closer to React-ness.

But the problem with such a component is that if we want to use the player again somewhere, we will have to exactly repeat a third of this code.

Wrapping the player in a declarative React component

Let's separate the player into a separate declarative React component so that it can be easily reused elsewhere in the application.

To do this, it is useful to imagine how, ideally, it would be used, its interface with its main properties. Something like this:

<VideoPlayer 
  source={source} 
  status={status} 
  onStatusChange={(status) => setStatus(status)}
/>

This will suffice for now, but as we use it we’ll figure out what’s missing.

It turns out that the following should move to VideoPlayer:

  1. Variable player.

  2. Player initialization code and the parameters needed for this.

  3. div in which the player is embedded.

type PlayerProps = { 
  source: string;
  status: Status;
  onStatusChange: (status: Status) => void;
}

const VideoPlayer: React.FC<PlayerProps> = (props) => {
  const playerElem = useRef<HTMLDivElement>(null);
  const player = useRef<Player>();
  
  useEffect(() => {
    player.current = new Player();
    player.current.applyElement(playerElem.current);
    player.current.setSource(props.source);
    switch (props.status) {
      case "playing": player.current.play(); break;
      case "paused":  player.current.pause(); break;
      case "stopped": player.current.stop();  break;
    }
    player.current?.addListener("statusChange", props.onStatusChange);
    return () => player.current?.destroy();
  }, []);
  
  return <div ref={playerElem}/>;
};

VideoPlayer can now be reused without having to repeat the given useEffect.

Tracking changes in props fields

If you click on the Play and Stop buttons, you will find that the player does not respond to them at all.

This is because source and status are set only once when the VideoPlayer component is initialized. And when they change, the corresponding player methods are not called.

Let's move them into separate useEffects to track their changes:

  const VideoPlayer: React.FC<PlayerProps> = (props) => {
  const playerElem = useRef<HTMLDivElement>(null);
  const player = useRef<Player>();
  
  useEffect(() => {
    player.current = new Player();
    player.current.applyElement(playerElem.current);
    player.current.addListener("statusChange", props.onStatusChange);
    return () => player.current?.destroy();
  }, []);
  
  useEffect(() => {
    player.current?.setSource(props.source); // перенесли
  }, [props.source])
  
  useEffect(() => {
    switch (props.status) { // перенесли и обработали все значения
      case "playing": player.current?.play(); break;
      case "paused":  player.current?.pause(); break;
      case "stopped": player.current?.stop();  break;
    }
  }, [props.status]);
  
  return <div ref={playerElem}/>;
};

useEffect fires its callback when the dependency array changes. And there we have props.source and props.status, the changes of which we want to track. Therefore, the player now responds to source and status changes.

Please note that the first useEffect must be the one that the player creates. Because the rest of useEffect needs an already created player. If it is not there, then they will not work until their dependency array changes. And the video will not be shown in the player until the user clicks Play.

Therefore, you need to pay attention to the order of useEffect (see. The post-Hooks guide to React call order).

Note: latest version of React documentation advises track props changes not in useEffect, but directly in the body of the component function. Because then you can avoid unnecessary rendering cycles. But this is not our case – we call the methods of the native player, so there will be no unnecessary renderings.

Tracking changes in props events

There is the same problem with the onStatusChange handler – it is now added once when the player is initialized. This is bad because… you can't change it. Let's do it by analogy with props fields:

  useEffect(() => {
    const onStatusChange = props.onStatusChange;
    if (!player.current || !onStatusChange) return;

    player.current.addListener("statusChange", onStatusChange);
    return () => player.current?.removeListener("statusChange", onStatusChange);
  }, [props.onStatusChange]);

There are two interesting points here:

  1. To remove the previous handler, use the return value of useEffect. Then there is no need to store a link to the handler anywhere separately.

  2. But Typescript suggests that the props object could have arrived differently. Therefore, we have to copy the onStatusChange reference from the props object to a local variable so that removeListener uses the same reference that was passed to addListener.

Frequently changing properties

The player has some properties that can change quite often. For example:

I would like to do the same as with other properties:

<VideoPlayer
  position={position}
  onPositionChange={(position) => setPosition(position)}
  source={source} 
  status={status} 
  onStatusChange={(status) => setStatus(status)}
/>

But there are three problems:

  1. onPositionChange is called very often – this will constantly re-render the parent component.

  2. The video is played by the browser in a separate thread, and the position update will not keep up with it. Constant position={position} will make the video slow down and jerk.

  3. useEffect will run with a delay – after rendering is completed. Sometimes this can be important. Then the corresponding player method should be called on the event, and not in useEffect after rendering.

That is why in libraries where a high component update rate is needed, it is often necessary to move away from a declarative approach to an imperative one. Or do something specifically to avoid unnecessary rendering.

For example, in React Spring there is a concept Animated Components — specially wrapped components that are used as regular declarative ones, but “under the hood” they work directly with DOM elements.

Therefore, it is better to leave the VideoPlayer part of the API imperative, for example like this:

type PlayerApi = {
  seek: (position: number) => void;
};

type PlayerProps = {
  api?: MutableRefObject<PlayerApi | undefined>;
};

const VideoPlayer: React.FC<PlayerProps> = (props) => {
  ...
  useEffect(() => {
    if (props.api) {
      props.api.current = {
        seek: (position: number) => player.current?.seek(position)
      };
    }
  }, [props.api])
  ...
}

export default function App() {
  const playerApi = useRef<PlayerApi>();
  ...
    <button
      onClick={() => playerApi.current?.seek(0)}
    >
      Seek beginning
    </button>
    <VideoPlayer
      api={playerApi}
      source={SOURCES_MOCK}
      status={status}
      onStatusChange={setStatus}
    />
  ...
}

But if you imagine that position will need to be derived from the state of other elements, and not set directly based on the click event. Then, you will have to create a separate useEffect in the App, similar to what was done above in VideoPlayer. And call our API in it.

So

In order to make a declarative component from a native imperative component, you need:

  1. Move its initialization and destruction code into a separate React component. And also, the DOM element to which it will be attached.

  2. Add code to useEffect that tracks changes to individual fields and calls the corresponding component methods.

  3. Add subscription and unsubscribe from events to useEffect.

  4. Wrap frequently changing properties in a special imperative API and provide it to the parent component.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *