20
Introducing mlyn - new state management for React
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.
20