Events Architecture for React and WebAssembly

Events Architecture for React and WebAssembly

At Canvas our frontend architecture combines React and WebAssembly (Wasm). Code that needs to be highly performant is written in Rust and compiled to WebAssembly; everything else is written in React. This architecture lets us write fast code when it's needed while writing code fast when it's not.

Besides getting the project to build, the biggest challenge of this architecture has been communication between the React and Wasm apps. This feels more like an API boundary in a services architecture than a single-page application.

We’ve spent a lot of time getting this working smoothly and I’m happy enough with our setup to share our current design. In this post, I’ll describe our basic architecture, one of the problems we encountered, and how we used DOM events to address it.

The basics

For more background on WebAssembly I recommend reading my previous post. The upshot is that after writing your application in Rust and compiling to Wasm, you end up with an executable binary and some boilerplate for importing it into your Javascript code.

Shown below is the start function that starts up the Wasm application. This function can accept arguments and return values. We use this to pass in React functions to Wasm, and return Wasm functions to React:

// importing WebAssembly code
import { start } from '../../canvas/pkg';

const clearContextMenu = () => setContextMenu(null);
// callbacks React functions that Wasm can call
const callbacks = {
  clearContextMenu,
  ...
};
// closures are Wasm functions that React can call
closures = start(
  callbacks,
  getCanvasHostname(),
  getCanvasProtocol(),
  ...
);
closures.save_query();

This handles basic two-way communication between the two applications, but doesn't get us all the way home.

The feature

To illustrate the shortcomings of this design I’ll use a simple, real example.

Our product, Canvas, is shown above. The navigation bar, toolbar, and SQL editor are owned by React. Everything in between is owned by Wasm, which handles storing and executing SQL queries as well as rendering the results with WebGL. We call this WebGL area the "canvas" (as its also known by the DOM).

We needed the ability to toggle the SQL editor between open and closed. And importantly, we needed to do this from both React and Wasm. If the button is selected in the navigation bar, React opens the editor. If the hotkey is pressed while the Canvas is active, Wasm opens the editor. This small requirement exposed a major edge case.

The problem

The straightforward way to implement this is to create React state controlling the open/closed state of the SQL editor, then pass to Wasm a function that toggles the state back and forth:

import { start } from '../../canvas/pkg';

// does not work
const [sqlEditorOpen, setSqlEditorOpen] = useState<boolean>(false);
const toggleSqlEditor = () => {
  setSqlEditorOpen(!sqlEditorOpen);
};
const callbacks = {
  toggleSqlEditor,
};
start(
  callbacks,
  ...
)

Unfortunately, this doesn’t work because we are passing closures to Wasm. In the example above, toggleSqlEditor closed over the value sqlEditorOpen when it was initialized as false. This means anytime our WASM application called this function, we only call setSqlEditorOpen(true).

In a pure React app, React would trigger a re-render of the child component anytime one of the props changed, so toggleSqlEditor would always be called with the most recent value of sqlEditorOpen. In our case, this would require restarting the entire Wasm application. For an application rendering at 60 fps, this is a non-starter.

We also tried moving all of the state into Wasm, with Wasm fully controlling the logic and React handling the UI. Unfortunately, this led to tons of duplicated state since even just “managing the UI” required storing most of the values.

Events

Thinking about the “correct” way to solve the problem, the SQL editor is a UI element owned by React; so, it should be fully controlled by React. Moving control into Wasm was poor design born of technical necessity. Wasm simply needs to notify React when an action occurred and let React handle that how it likes.

We already had some prior art for this pattern in our application. User actions like keydown and mouseup are already issued to the DOM as Events. Both our React and Wasm applications listened for and handled these events.

Our final solution uses this event architecture by way of CustomEvents. CustomEvents are supported by all major browsers for exactly this purpose, allowing applications to dispatch events for any purpose. Initializers control the event flow and can include extra details on the event.

Events target specific elements (for example a <div> receives a mousedown) and bubble upwards unless canceled. This means that parent elements receive the events of their children. If you want an element to receive an event, you must target the specific element or one of its children.

Implementation

To use custom events we need to include the CustomEvent and CustomEventInit features for web-sys in our Cargo.toml:

// Cargo.toml
...
[dependencies.web-sys]
version = "0.3.4"
features = [
  'CustomEvent',
  'CustomEventInit',
	...
]

Then we add a function to create and dispatch custom events in Rust:

pub fn send_custom_event(event_type: &str) -> Result<(), String> {
    let event = CustomEvent::new_with_event_init_dict(
        "togglesqleditor",
        CustomEventInit::new()
            .bubbles(true)
            .cancelable(true),
    )
    .map_err(format_error_with_debug)?;
		// get an element that is a child of the element you want to catch in
    let app_element = get_canvas().map_err(format_error_with_debug)?;
    app_element
        .dispatch_event(&event)
        .map_err(format_error_with_debug)?;
    Ok(())
}

In React we add a listener for this event:

React.useEffect(() => {
    const handleEmptySqlQuery = (_e: Event) => {
        setSqlEditorOpen(!sqlEditorOpen)
    };
    window.addEventListener('togglesqleditor', handleEmptySqlQuery);

    return () => {
        window.removeEventListener('togglesqleditor', handleEmptySqlQuery);
    };
// useEffect will handle updating the closed-over variables added here
}, [sqlEditorOpen, setSqlEditorOpen]);

With all that in place, we can finally toggle our SQL editor to our hearts’ content.

May you live in eventful times

While this code is fully-functional, we’re not able to include extra information like we typically need (for example, including the element id or SQL query when opening the editor). We also have magic strings and no type-safety to guarantee we’re subscribing to an event that even exists. In the next post, I’ll cover how we implemented this and a reusable React hook for subscribing to events.

One of the pleasures of working outside established frameworks is discovering the value of certain patterns from scratch and then hacking them into existence. If this kind of thing sounds found to you, please say hello!