Suppose you have the following app:
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 :)
Top comments (0)