Skip to Content

Introducing Standard View and React-Three-Fiber Context Bridge

by Maytee Chinavanichkit on

Introduction

Standard enables autonomous checkout for brick & mortar retailers with modern artificial intelligence techniques. To better visualize how our machine learning algorithms understand the things that happen within our stores, we built Standard View. Standard View's purpose is to speed up 3D tools development using React by hiding the complexity of three.js for non-graphics engineers. The library was built on top of react-three-fiber, a library where we can define three.js components in a declarative way via React syntax.

Standard View is a foundational block for many of our internal tools. We ran into a tricky bug during the development of our tools when combining these libraries, one of such is react-redux. You will likely encounter the same issue when using react-three-fiber with any other state-management library or React context. This post will explain how we fixed the problem, generalized the solution, and explored React's internals.

The problem

To draw 3D components with react-three-fiber, we must wrap all the components inside react-three-fiber's Canvas component. This wrapper component acts as a boundary to separate react-three-fiber's reconciler from the React default reconciler. The reconciler is the library that converts JSX into another format. By default, it converts JSX into DOM. React-three-fiber uses this to generate three.js nodes.

Child components can create and read the context data within the wrapper. Hooks likewise can access this data. General components outside this wrapper can also do this. However, context data does not flow across the wrapper. This is a known limitation on react-three-fiber Github page.

The context boundary is a problem for us. We wanted to build UIs that can toggle on/off visualizations. However, the limitation suggests that we encasing the application inside the Canvas component. We would rather keep the toggle controls and the 3D components separate and share some context between the two. So we tried to connect the two by building a context bridge.

A Simple ContextBridge

The most straightforward context bridge manually connects the outer context consumer with the inner context provider.*

<Context.Consumer>
    {value => (
     <Canvas>
       <Context.Provider value={value}>
         <Square />
        </Context.Provider>
     </Canvas>
    )}
</Context.Consumer>

Key Ideas:

  • A reference value is hardcoded from an external <Context.Consumer />, across the Canvas component, and into a <Context.Provider />
  • <Context.Consumer /> only triggers an update when its value changes.
  • <Context.Provider /> performs a shallow comparison on every new value to determine whether a re-renders is needed. This optimization stops React from re-rendering unnecessarily.

A General ContextBridge

Great! The simple ContextBridge works sharing exactly one context. In general, we have to deal with many, many more. Let's try to build a generalized solution. The solution above breaks down into three components; a context listener, a context provider, and a bridge unit. The context listener can be generalized to a ContextListeners component, which will listen to all changes from the outer context. The context provider, ContextProviders component, to pass along all updated contexts. And the bridge unit, CanvasWithContextBridge component, connects the two components and replace the Canvas component.

ContextListeners

The ContextListeners component listens for changes from the outer contexts. To achieve this we need access to the list of contexts and a list of each contexts' value. Then for each, we cache each value into a shared list.

const ContextListeners = memo(({
  contexts,
  values
}) => contexts.map(
    (context, index) => (
      <ContextListener
        context={context}
        values={values}
        index={index}
      />
    )
))

const ContextListener = memo(({ context, values, index }) => {
  values[index] = useContext(context);
  return null;
})

Key Ideas:

  • We use the useContext hook to fetch the latest context value automatically.
  • We want to loop through the contexts, but this violates the rules of hooks. Using a map function is a workaround for the check.

Note: The listener is no longer wrapping the providers like in the simple ContextBridge, therefore renders for child components are dropped.

ContextProviders Component

The ContextProviders component pushes context value updates to all the children inside the Canvas component. It takes a list of contexts and a list of values from the ContextListeners and the children of Canvas.

const ContextProviders = memo(({
  contexts,
  values,
  children
}) => {
  const [, update] = useState();

  useFrame(() => update({}));

  if (!contexts || !values) {
    return <>{children}</>;
  }

  return contexts.reduce(
    (child, Context, index) => (
      <Context.Provider value={values[index]}>
        {child}
      </Context.Provider>
    ),
    children
  );
});

Key Ideas:

  • Again we need to work around the rules of hooks. We use a reduce function to recursively embed the <Context.Provider /> components.
  • We need a way to tell React to recheck the context values. The useFrame hook provided by react-three-fiber is used to queue up this check before the next render call executes.
  • An empty update setter is used to force React to recheck whether the component needs re-rendering.

Note: This check on every frame is, in practice, not expensive. If updates were to happen faster than the animation frames, these will get batched and only rendered once on the next frame. On the other hand, if the updates are sparse, the Context.Provider's shallow comparison check will prevent unnecessary re-renders. Only when a value changes and some child component uses the context, then a render will trigger.

CanvasWithContextBridge Component

The CanvasWithContextBridge connects react-three-fiber's Canvas component with the above ContextListeners and the ContextProviders.

const CanvasWithContextBridge = memo(({contexts, children}) => {
  const values = useRef([])
  return (
    <>
      <ContextListeners contexts={contexts} values={values.current} />
       <Canvas>
         <ContextProviders contexts={contexts} values={values.current}>
          {children}
         </ContextProviders>
       </Canvas>
    </>
  )
})

Key Ideas:

  • We use a useRef hook to our shared array of Context values.

With this last part, the CanvasWithContextBridge is ready to replace existing uses of react-three-fiber's Canvas. 🥳

Solving the Problem

The context boundary problem is no longer a problem with the following code. This solution will render two empty meshes when the context value is true. A working copy of the example can be found here.

import React, { createContext } from "react";

const Context = createContext();

const Toggle = () => {
  const toggle = useContext(Context);
  return <input type="checkbox" checked={toggle} />;
}

const ToggleableMesh = () => {
  const toggle = useContext(Context);
  return toggle ? <mesh> : null;
}

const Page = () => 
  <CanvasWithContextBridge contexts={[Context]}>
    <ToggleableMesh />
    <mesh />
  </CanvasWithContextBridge>


const App = () => 
  <Context.Provider value={true}>
    <Toggle />
    <Page />
  </Context.Provider>

Conclusion

This ContextBridge is what we use internally at Standard Cognition. It powers the View3D inside Standard View. We hope this gives you insights into the workings of React and react-three-fiber. Hopefully, this solution also allows you to build even more complex and amazing applications with react-three-fiber. A working example of a simple ContextBridge and the full ContextBridge is available here. Standard View is open-sourced and available here.

PS: Beyond custom context, our 3D tools work primarily with redux and react-redux. Adding the following lines to your application will allow you to bring redux states to your react-three-fiber canvas(es).

import { ReactReduxContext } from "react-redux";

<CanvasWithContextBridge contexts={[ReactReduxContext]} />
* Solution provided by francisco
  1. https://github.com/facebook/react/issues/13332#issuecomment-513088081

Sharethis page:Share on TwitterShare on LinkedIn