Theodo logo

How to convert an imperative library to a declarative React component: the Sketchfab Viewer

April 10, 2020Alex de Boutray6 min read

I love using React for multiple reasons. I like the writing style, I like the tooling, I like the ecosystem.

But sometimes you run into a library that doesn't have a React adapter. It can either be pretty old, kind of niche, closed source, etc.

In this series of articles, we’ll look into how to convert an existing imperative JS library into a declarative React component.

We’ll use the Sketchfab Viewer as an example. Sketchfab develops an amazing 3d suite that includes a very configurable web viewer for use with their models.

You can see that their doc is pretty extensive, if a bit messy. It includes a lot of examples and it took me a long time to run into a use case that was not already covered. Yet you can also see that they use an imperative style of JS that doesn’t fit well into the React patterns.

api.addEventListener("viewerready", function () {
  console.log("Viewer is ready");
});

We are going to build a React function component on top of that library. It will accept a config object to declaratively change a color on the model. It will also expose a callback to react to a click event on a part of the model. And we'll use hooks because they're fun!

You can find the final code for this part here.

  • Part 1: Initializing and exposing the api object
  • Part 2: Working on it!

Initializing and exposing the api object

Throughout this project we'll work with this fancy chair, courtesy of the official Sketchfab examples.

The default Sketchfab chair

Start the project with create-react-app

First things first, let's generate a basic CRA project:

npx create-react-app sketchfab-react

In src/, add a Viewer.jsx file and create an empty component inside.

import React from "react";

export const Viewer = () => <></>;

Add that component inside App.jsx

import React from "react";
import "./App.css";
import { Viewer } from "./Viewer";

function App() {
  return (
    <div className="App">
      <Viewer />
    </div>
  );
}

export default App;

Now let's import the library. Sketchfab provides a package install through npm but that's not always the case for other libraries, so we'll include the source in the head of public/index.html.

<script
  type="text/javascript"
  src="https://static.sketchfab.com/api/sketchfab-viewer-1.7.1.js"
></script>

This import will make window.Sketchfab available globally. If you're using Typescript, you will need to ignore it.

Render the Sketchfab Viewer in an iframe

According to the docs, we need to create an iframe and give it to the Sketchfab constructor to render the Viewer.

The way to do this in React is, in Viewer.jsx:

import React, { useEffect, useRef } from "react";

// Our wonderful chair model
const MODEL_UID = "c632823b6c204797bd9b95dbd9f53a06";

export const Viewer = () => {
  // This ref will contain the actual iframe object
  const viewerIframeRef = useRef(null);

  const ViewerIframe = (
    <iframe
      // We feed the ref to the iframe component to get the underlying DOM object
      ref={viewerIframeRef}
      title="sketchfab-viewer"
      style={{ height: 400, width: 600 }}
    />
  );

  useEffect(
    () => {
      // Initialize the viewer
      let client = new window.Sketchfab(viewerIframeRef.current);
      client.init(MODEL_UID, {
        success: () => {},
        error: () => {
          console.log("Viewer error");
        },
      });
    },
    // We only want to initialize the viewer on first load, so we don't add any dependencies to useEffect
    []
  );

  return ViewerIframe;
};

And you should have the viewer displaying!

The Sketchfab viewer embedded on a page

Expose the api object to access it imperatively

One of the great things about the Viewer API is that it allows us to transform our model from the browser. Here, we'll try to make the chair red.

First we need to extract the api object and store it. We could put it in the state of our Viewer component, but we'll want to use it higher up in the tree, so we will pass a ref as prop and give it the api.

export const Viewer = ({ apiRef }) => {
	// ...
        success: api => {
          // Store the initialized api inside the provided ref
          apiRef.current = api;
        },
	// ...
};

Now, let's get it inside App.

function App() {
  const apiRef = useRef(null);

  return (
    <div className="App">
      <Viewer apiRef={apiRef} />
    </div>
  );
}

To check that it works, let's try to change the color of the background. There's a method for that!

Let's add this code to App.jsx:

function App() {
  // ...
  const changeBackgroundColor = () => {
    apiRef.current.setBackground({
      color: [Math.random(), Math.random(), Math.random(), 1],
    });
  };

  return (
    <div className="App">
      <button onClick={changeBackgroundColor}>Change background color</button>
      <Viewer apiRef={apiRef} />
    </div>
  );
}

Save, click on the button, voilà!

If it doesn't work, check that you have clicked on the play button of the viewer beforehand to load the model ;)

The viewer with the background changed

Change the chair color

Alright, that function is a bit bigger but you'll have to trust me.

function App() {
  // ...

  const changeChairColor = () => {
    apiRef.current.getMaterialList((err, materials) => {
      const plasticMaterial = materials.find(
        (material) => material.name === "Blue plastic"
      );
      plasticMaterial.channels.AlbedoPBR.color = [
        Math.random(),
        Math.random(),
        Math.random(),
      ];
      apiRef.current.setMaterial(plasticMaterial, () => {
        console.log("Updated chair color");
      });
    });
  };

  return (
    <div className="App">
      <button onClick={changeBackgroundColor}>Change background color</button>
      <button onClick={changeChairColor}>Change chair color</button>
      <Viewer apiRef={apiRef} />
    </div>
  );
}

A new color for our chair!

The viewer with the chair color changed

Hooks refactor

To abstract the initialization code and clean up our component, we'll write a custom hook that leverages all the code previously written plus a useState for the api object.

Custom hooks are one of the most powerful tools to keep our code well organized. Once you start using them, there's no going back.

import React, { useEffect, useRef, useState } from "react";

// Our wonderful chair model
const MODEL_UID = "c632823b6c204797bd9b95dbd9f53a06";

const useSketchfabViewer = () => {
  // This ref will contain the actual iframe object
  const viewerIframeRef = useRef(null);
  const [api, setApi] = useState();

  const ViewerIframe = (
    <iframe
      // We feed the ref to the iframe component to get the underlying DOM object
      ref={viewerIframeRef}
      title="sketchfab-viewer"
      style={{ height: 400, width: 600 }}
    />
  );

  useEffect(
    () => {
      // Initialize the viewer
      let client = new window.Sketchfab(viewerIframeRef.current);
      client.init(MODEL_UID, {
        success: setApi,
        error: () => {
          console.log("Viewer error");
        },
      });
    },
    // We only want to initialize the viewer on first load
    // so we don't add any dependencies to useEffect
    []
  );

  return [ViewerIframe, api];
};

export const Viewer = ({ apiRef }) => {
  const [ViewerIframe, api] = useSketchfabViewer();

  apiRef.current = api;

  return ViewerIframe;
};

What we learned

  • We can use refs to feed DOM elements to libraries that need them.
  • We can use refs to give back objects to parents. Another solution could have been to use a React Context.
  • We can use custom hooks to make our code cleaner and more readable.

You can find the final source code here.

Shout-out to Sketchfab for their amazing product and in particular to Arthur Jamain for the great collaboration we had over the last few months.

Alex de Boutray

Alex de Boutray

Software Engineer at Theodo US