Fine-grained reactivity in React with Mlyn

Mikhail Boutylin
Frontend Weekly
Published in
4 min readNov 2, 2021

--

You might hear recently about great frameworks like Svelte and Solid, that aim to become react replacements. One of the complaints about react is that it might become hard to manage re-renderings and hence huge virtual dom updates or expensive recomputations. Also taking care of memoization might become a tricky task. Let say you have auseCallback hook with some dependencies. You should take care to not only list properly all those dependencies but also be confident, that dependencies themselves are properly cached (in case of an object, array, or function). Otherwise, this useCallback will become a poorly memoized dependency and will cause on its own breaking memoization for dependent hooks and components. This cascade might be triggered in an unexpected place of the app, and it might become a complex and annoying issue to debug.

However, let assume we did a good job of memoizing our hooks and components:

import React, { useCallback, useState} from "react";interface FieldProps {
onChange(e: any): void;
value: string;
label: string;
}
const Field = React.memo((props: FieldProps) => {
const { onChange, value, label } = props;
const onChangeInternal = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
},
[]
);

return (
<div>
{label}
<input
value={value}
onChange={onChangeInternal}
/>
</div>
);
});
export const Form = React.memo(() => {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");

return (
<div>
PLAIN REACT
<Field
label="First Name"
value={firstName}
onChange={setFirstName}
/>
<Field
label="Last Name"
value={lastName}
onChange={setLastName}
/>
<Field
label="Email"
value={email}
onChange={setEmail}
/>
</div>
);
});

Virtual dom will not create a huge diff on this component, however updating firstName will still cause re-running full code of Formand one of Field components. This can be easily noticed if you turn on the Highlight updates when components rerender. option in react dev tools.

Svelte and Solid do propose a way to create subscriptions to atomic elements in a declarative way, and hence to increase update performance by reducing recalculation price. On the other side, they suggest not using virtual dom, cause all updates will be delivered to the address automatically.

As an attempt to achieve the same level of control, but with react and with virtual dom, I’ve created a library called mlyn and react bindings to it. Within it, you can create a subject that will hold your immutable state, and provide a lens to read / update / subscribe to a specific part of the state. In a previous article, I’ve mentioned a low-level approach to using the library, which will help to understand how it works under the hood. However we can achieve even less verbose and more granular update experience, we re-render ONLY what we need:

As you can see above we re-render only the element we want.

import React, { useCallback } from "react";
import Mlyn, { useSubject, seal } from "react-mlyn";
import { Subject } from "mlyn";
interface FieldProps {
label: string;
value$: Subject<string>;
}
const Field = seal((props: FieldProps) => {
const { label, value$ } = props;
const onChangeInternal = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
value$(e.target.value);
}, []);
return (
<div>
{label}
<Mlyn.input
value$={value$}
onChange={onChangeInternal}
/>
</div>
);
});
export const Form = seal(() => {
const subject$ = useSubject({
firstName: "",
lastName: "",
email: "",
});
return (
<div>
REACT WITH MLYN
<Field label="First Name" value$={subject$.firstName} />
<Field label="Last Name" value$={subject$.lastName} />
<Field label="Email" value$={subject$.email} />
</div>
);
});

Let analyze the code above:

First of all we create a subject, that will hold our state.

const subject$ = useSubject({
firstName: "",
lastName: "",
email: "",
});

Then we pass to the Field component a lens to a field.

<Field label="First Name" value$={subject$.firstName} />

When we write subject.firstName we don’t pass it value, instead, we pass a readable, writable, and observable proxy to firstName . BTW, notice how it’s simple to achieve 2-way binding.

As you can notice Field is a sealed component, which means it will just never re-render by property change. All updates should be reactive via mlyn subscriptions.

const Field = seal((props: FieldProps) => { ... });

To update the value of passed projection (in our case firstName ), we just consume it as value$ and invoke it:

const onChangeInternal = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
value$(e.target.value);
}, []);

And the most interesting part:

<Mlyn.input
value$={value$}
onChange={onChangeInternal}
/>

Mlyn.input is a plain react dominput wrapped with utility that creates properties with trailing$ which will mean that this is a reactive property that will cause re-rendering whenever observable value will dispatch.

Hopefully, this approach will help you achieve a better developer experience, by simplifying connections among your components and simplifying performance-oriented tuning.

--

--