Proxy / Observable as property pattern in React

Suppose you have the following app:
Application wireframe

In this particular state you have Box1 element selected, and want to change it backgroundColor style by the color picker in the Details component. Let describe some possible implementations in React:

(Scroll down if want to bypass prop-drilling / context / redux approaches and go directly to proxy / observable approach).

Prop-drilling

In this case we would lift the state that contains all elements to the top of the app (Application component). We would pass to the Details component the selected element, and a callback (updateComponent) to modify it. Then on color selection this callback updateComponent would be invoked, which would update state of Application component. Afterwards Application, Canvas and Box1 components would be re-rendered and finally background color will be updated.

Pros

Simple implementation to develop and support.

Cons

This would cause invalidation of all hooks (useCallback / useEffect / useMemo) to check if they need to update. Also re-rendering Canvas would cause invalidation of properties of all boxes (need to check if incoming properties changed for this specific box). In real-world application you'll get even more dependencies to update (for sure Canvas will not be the only child of Application). Also this is positive scenario, which suppose that all memoization in your app is properly managed.

This will certainly work fine if you update color only when releasing color picker. But what if you want to update the color of Box1 on every mouse move to get a handy preview experience? I think in some cases it will still work, but at certain point you might reach performance wall, that will force you to optimise your application. And in this case simple implementation might become not so simple.

Also you will not only need to pass down the state, but also callbacks to update it.

Context / Redux

I grouped those two approaches, cause they solve this problem in a similar way. The state is stored in a context which than is injected into components via hooks (react-redux uses context under the hood as well). So when the state stored in context is updated, all dependent components are notified.

Pros

Since you don't pass the pass the property / update callbacks through the intermediary components, amount of passed properties is reduced. The problem of re-rendering intermediate components is solved as well.

Context cons

All components subscribed to context via useContext re-renders when it's updated. This problem might be solved by fragmenting different parts of the state to different contexts. But I'd prefer application data to be separated in base of logical distinction, rather than in base of thinking how it will re-render less.

Redux concerns

In redux, all components that are subscribed via useSelector hook are notified, but than a selector is run to extract selected state, afterwards it figures out, if that component actually need to be re-rendered. This mostly solves the re-rendering issue, but still, more components are subscribed to the store, more selector logic need to happen.

As another concern I need to state, that unfortunately I saw many situations, when some complex (or parametrised) selectors where written in a wrong way, from the memoization standpoint. And this would make component re-render on every store update (even of data completely unrelated to the re-rendered component). Those memoization issues are quite hard to debug.

One more issue, is that within useSelector hook you need to reference full application state. Which means if your module consumes user data, it has to be aware that this user data is stored under user key in the root state. Not good for modules decomposition. In general context (and especially with redux) makes it harder create reusable components, and bootstrap unit tests / storybook.

Proxy / Observable as property

However React doesn't force component properties to be plain values. You can easily pass as property an observable value to a child and then internally subscribe to it. Let write some pseudo-code to explain it:

const Application = () => {
  const elements = createObserable([]);
  return <Canvas elements={elements} />
}

Then inside a consumer component you can subscribe to it value.

const Box = ({ element }) => {
  const [backgroundColor, setBackgroundColor] = useState(0);
  useEffect(() => {
    const unsubscribe = element.backgroundColor
      .subscribe(value => {
        setBackgroundColor(value);
      });
    return () => {
      unsubscribe();
    };
  }, []);
  return <div style={{ backgroundColor }} />;
}

Looks like a lot of boilerplate is needed. Also within this approach all Box component function need re-execute. Suppose for example situation when component has more than one child. But what if we create an ObserverDiv component, that will detect all observable properties automatically, then the code can be reduced to:

const Box = ({ element }) => {
  const { backgroundColor } = element;
  return <ObserverDiv style={{ backgroundColor }} />;
};

This is very similar to prop-drilling, but on change of backgroundColor for one element only ObserverDiv will be re-rendered and the rest of the app will remain untouched. Very similar to the context / redux approach, but without related concenrns.

The next question is how we can we make every element property (like element.backgroundColor) observable. Here's where proxy enters in the game. Within a javascript proxy object you can override get accessors, and return another proxy, which will create a lens to backgroundColor, now you can directly subscribe to it.

To solve everything described above I've a created library called mlyn. Within it you can create proxies, that can be lensed, subscribed and updated. And yeah, internally those proxies contains immutable objects, so none of react best practices are violated. How this app would look with mlyn:

import Mlyn, { seal, useSubject, For } from "react-mlyn".

const Application = seal(() => {
  const elements$ = useSubject([{
    id: "some-random-id",
    backgroundColor: "black",
  }]);
  return <Canvas elements$={elements$} />
});

const Canvas = seal(({ elements$ }) => {
  return (
    <For each={elements$} getKey={({ id }) => id}>
      {(element$) => <Box element$={element$} />}
    </For>
  );
});

const Box = seal(({ element$ }) => {
  const { backgroundColor } = element$;
  return <Mlyn.div styles$={{ backgroundColor }} />;
});

And now when you change backgroundColor of an element, only the Mlyn.div component will be re-rendered.

To see mlyn in action, please checkout my previous article about it.

Have a nice day :)

25