Introducing mlyn - new state management for React
vaukalak
Posted on November 22, 2021
Impressed by fine-grained reactivity concept from solid-js, I've tried to build a library that brings it to react. Some react issues I was going to solve where:
- Provide possibility to re-render just those elements, which related data has changed.
- Enable easy 2-way binding, however maintaining unidirectional data flow.
- Remove necessity to overflow the code by explicitly mentioning all dependencies, as we currently do with
useEffect
,useCallback
anduseMemo
. - Issues with encapsulation and modularisation when using redux or context as state management (I ❤️ redux btw).
Now I'm going to present you main concepts of the library within a TodoMVC app example. You can find full source code here. Note that example fits in less than 60 lines of code.
First of all let define our component:
export const App = seal(() => {
// ...
});
seal
is an import from react-mlyn
, it's a wrapper of React.memo
, which compare function always returns true
. Which means, component should never re-render by incoming properties change (those are not supposed to ever change). All children re-renders will be triggered by mlyn reactivity system.
Now let define the state:
const state$ = useSubject({
todos: [],
newTitle: ""
});
useSubject
is a react-hook, that will convert initial state to a subject
. A subject in mlyn is a proxy object, which can we used in 4 different ways:
- you can read from it:
// will return actual state
state$();
- you can write to it:
// will set `newTitle` to `hello`
state$({
...state$(),
newTitle: "hello",
});
- you can subscribe to it:
useMlynEffect(() => {
// will log the `state$` value every time it's updated
console.log(state$());
});
By reading state$
inside of useMlynEffect
hook we automatically set it as a dependency, which will re-run the hook every time state$
has been updated.
- you can lens it:
state$.newTitle("hello");
state$.newTitle(); // hello
state$(); // { newTitle: "hello", todos: [] }
Every lens behave like a subject, but when updated bubbles an immutable update to the root subject. Also within lens you can subscribe to updates of just a portions of the state.
Now let go back to our TodoMVC app, let create a synchroniser of todos to the local storage:
// this hook accepts a subject and a string key for localstorage
const useSyncronize = (subject$, key) => {
// if localStorage already contains info for that key,
// let write it to `subject$` as initial state
if (localStorage[key]) {
const preloadedState = JSON.parse(localStorage[key]);
subject$(preloadedState);
}
// create a subscription to `subject$` and write
// write it to localStorage when updated
useMlynEffect(() => {
localStorage[key] = JSON.stringify(subject$());
});
};
Invocation of this hook in the component code:
// create a lens to `state$.todos` and
// bind it to localStorage `todos` key.
useSyncronize(state$.todos, "todos");
Let create methods for adding / deleting todos:
const addItem = () => {
state$({
todos: [
// remember to use `()` when reading from a subject.
...state$.todos(),
{
title: state$.newTitle(),
createdAt: new Date().toISOString(),
done: false
}
],
newTitle: ""
});
};
This looks very similar to normal react update, but you don't need to wrap it with useCallback
since with mlyn
component is not going to be re-rendered.
const removeItem = (i) => {
state$.todos([
...state$.todos().slice(0, i),
...state$.todos().slice(i + 1)
]);
};
Note that since here you need to update just todos
you can directly write to state$.todos
without taking care of rest of the state. This is very handy, when passing a lens as a property to a child.
And finally jsx:
return (
<>
<h3>Simple Todos Example</h3>
<Mlyn.input
type="text"
placeholder="enter todo and click +"
bindValue={state$.newTitle}
/>
<button onClick={addItem}>+</button>
<For
each={state$.todos}
getKey={({ createdAt }) => createdAt}
>
{(todo$, index$) => (
<div>
<Mlyn.input type="checkbox" bindChecked={todo$.done} />
<Mlyn.input bindValue={todo$.title} />
<button onClick={() => removeItem(index$())}>x</button>
</div>
)}
</For>
</>
);
Notice that for inputs we use special tag Mlyn.input
it has some properties which enables subscriptions to mlyn reactivity. One of those is bindValue
. When you pass state$.newTitle
to it, it will both update the input when the newTitle
is updated, and write to newTitle
when input is changed. In short, this is 2-way binding.
<Mlyn.input
type="text"
placeholder="enter todo and click +"
bindValue={state$.newTitle}
/>
Now let analyse how the For
component, that is used to display collections works:
<For
// pass subject which holds array to display
each={state$.todos}
// key extractor, it's used not only by react reconciliation,
// but also by `For` component logic.
getKey={({ createdAt }) => createdAt}
>
{(todo$, index$) => (
<div>
<Mlyn.input type="checkbox" bindChecked={todo$.done} />
<Mlyn.input bindValue={todo$.title} />
<button onClick={() => removeItem(index$())}>x</button>
</div>
)}
</For>
The first parameter $todo
of function child prop is still a 2-way lens. Which means, by updating it, you'll update todos
array and in general entire state. So writing:
todo$.title("new value");
Is like writing something similar to bellow in plain react:
setState({
...state,
todos: state.todos.map(item => {
if (getKey(item) === getKey(todo)) {
return { ...item, title: "new value" };
}
return item;
}),
});
You probably noticed that one input is a checkbox toggle for boolean value:
<Mlyn.input type="checkbox" bindChecked={todo$.done} />
bindChecked
is similar to bindValue
but it creates 2-way binding for a boolean subject value to input checked
field.
Posted on November 22, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.