Observer-based libraries like MobX, MobX-State-Tree, and others face a problem with React.

The observer() wrapper pattern that Mobx and similar libraries use is incompatible with React Compiler, and more generally it violates the Rules of React by making components impure.

Source.

But what does that mean?

Let's forget about specific libraries for a while and consider an extremely rudimentary observable library. Here's a minimal implementation of making JavaScript objects observable in React.

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

let pendingListener = null;

export function ConvertObjectToState(target) {
const listeners = {};

const handler = {
get(target, prop, receiver) {
if (pendingListener) {
if (listeners[prop]) {
listeners[prop].push(pendingListener);
} else {
listeners[prop] = [pendingListener];
}
}

return Reflect.get(...arguments);
},
set(target, prop, value) {
Reflect.set(...arguments);

if (listeners[prop]) {
listeners[prop].forEach((callback) => {
callback();
});
}

return true;
},
};

const proxy = new Proxy(target, handler);
return proxy;
}

export function observer(Component) {
return function ObserverComponent(props) {
// trigger state to force re-render
const [state, updateState] = useState({});

useEffect(() => {
const rerender = () => updateState({});
// Add rerender to state manager's listeners
pendingListener = rerender;

return () => {
// Cleanup logic if necessary (e.g., removing the listener)
pendingListener = null;
};
}, []);

return <Component {...props} />;
};
}

See this in action on CodeSandbox.

This is an extremely over-simplified implementation. For example, the mobx-react observer function has a lot more going on. But hopefully you get the idea: we take an object, proxy any getters on that object to set up listeners when accessed, and those listeners get fired when the proxied setters are called for a specific property.

So that's a problem for React, because React requires rendering to be pure. Here's what they say about it in the docs:

One of the key concepts that makes React, React is purity. A pure component or hook is one that is:

  • Idempotent – You always get the same result every time you run it with the same inputs – props, state, context for component inputs; and arguments for hook inputs.
  • Has no side effects in render – Code with side effects should run separately from rendering. For example as an event handler – where the user interacts with the UI and causes it to update; or as an Effect – which runs after render.
  • Does not mutate non-local values: Components and Hooks should never modify values that aren’t created locally in render.

Keeping render pure allows React to internally assign priority to updates, and make other optimizations in the internal implementation that makes React as awesome as it is.

Since most observer wrappers have side-effects (like setting up listeners) which mutate non-local values (observer managers are not usually created locally in the render), React considers them to be in violation of the Rules of React.

This has always been the state of things. But with recent progress on the the React Compiler, we are finally faced with the concrete consequences of this incompatibility. MobX has an open issue right now that hasn't had activity for a few months. I think we need to proactively solve this problem as a community.

Many people, projects, and companies rely on observer-based patterns in React right now. If we can't find a path forward, those people will be in a tough position. On a day-to-day basis, they will miss out on optimizations made by the React Compiler. But in the bigger picture, the React community could evolve in such a way that makes it even harder to implement and adopt observability like this.

I hope we can avoid that future together, and I'm willing to do whatever work I can to help bridge the gap. I'm writing this post to hopefully open up the dialogue

A path forward

There is a glimmer of hope.

A potential path would be to make use() work with Mobx reactive values, but there's a lot to work through there (things like concurrent compatibility). And it would require additional work in Mobx and similar libraries to be compatible were we to do that

In fact, the RFC for the use hook includes a mention of observable values:

You can think of a "Usable" type as a container for a value. Calling use unwraps it.

const resolvedValue = use(promise);
const contextualValue = use(Context);

// Potential future Usable types
// (Purely hypothetical, not part of this proposal)
const currentState = use(store);
const latestValue = use(observable);
// ...and so on

What might that look like?

I've been reading through the React source code for use and the phenomenal React Internals Deep Dive by JSer to try and understand how this might work.

At the moment, my best understanding is:

  1. An author provides some kind of observable value to the use hook. That observable value implements a small interface agreed on by React.
  2. That interface provides the observable value a way to inform React of its intentions, and a way for React to communicate back to the observable value. Each observable library could implement a binding that satisfies the interface.
  3. With that binding, React could be in control of that conversation between Fiber and the observable. React could pass them over to its own system to manage side-effects. That keeps render pure, but allows observers to do what they need to.

Ok, but how do we implement it?

I'm finding the React codebase a little tricky to navigate, and the current contributing documents are only on the legacy site. They seem to be out of date. Instead of spinning my wheels alone, I decided to write this post with my work-in-progress code and see what happens.

For now, here's a PR against my own fork that demonstrates as much as I've been able to work out at a high level.

The code looks a little like this:

function use<T>(usable: Usable<T>): T {
if (usable !== null && typeof usable === 'object') {
// $FlowFixMe[method-unbinding]
if (typeof usable.then === 'function') {
// This is a thenable.
const thenable: Thenable<T> = (usable: any);
return useThenable(thenable);
} else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
const context: ReactContext<T> = (usable: any);
return readContext(context);
} else if (usable.$$typeof === 'REACT_OBSERVABLE_TYPE') { // TODO: set up the symbol value for REACT_OBSERVABLE_TYPE, or use some other property as preferred.
return readObservable(usable);
}
}

// eslint-disable-next-line react-internal/safe-string-coercion
throw new Error('An unsupported type was passed to use(): ' + String(usable));
}

/**
* In readObservable, we hook into some observable interface. Maybe it's a method called
* `registerObserver` that accepts some callback. React can take control of that method and
* delegate its call to some kind of safe process where side-effects can be run
* @param {*} observable
* @returns
*/

function readObservable(observable: any) {
// This is the observable method we might delegae elsewhere
const delegatedSideEffect = observable.registerObserver

// Then we could send that method to the commit phase (or other appropriate place)
// Method does not exist.
addSideEffectToCorrectQueue(delegatedSideEffect);

// Return the observable value, with the knowledge that React will set up the correct
// callbacks that trigger re-renders or schedule work when observable values changed.
return observable;
}

This is a mostly commented-out, hand-wavy implementation. If you want to see the much rougher things I've been trying out, along with a test that "runs", you can see that here.

What next?

There are probably better solutions that people with more experience in the React codebase can come up with and implement. I'm hoping this post will spur more conversation and other proposals.

I’m eager to support any solution that meets React's requirements and provides a viable path for observable library authors. If you have an idea like that, or you want to comment on this article and correct any of my mistakes, please find me on Twitter or email me at tyler@coolsoftware.dev if you want to collaborate.

I'd also welcome any comments on my high level fork PR or my actual implementation PR.

Updates

Update September 7, 2024 around midnight eastern: I think my toy implementation is working. Here is a GitHub comment about it. I am probably just making a mistake in the test setup.

Update September 8, 2024 around 9pm eastern: I figured out how to get the rendered output to change. When a fiber's memoizedProps haven't changed, it bails out early. And this new use(observable) code doesn't really know how to manipulate memoizedProps or otherwise inform React that there is valid work to be done. So as another hack, I updated the callback to set fiber.memoizedProps = null and force a full render cycle. It's a big hack, but it's enough to finish one complete test case.