DEV Community

vaukalak
vaukalak

Posted on

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 and useMemo.
  • 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(() => {
  // ...
});
Enter fullscreen mode Exit fullscreen mode

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: ""
});
Enter fullscreen mode Exit fullscreen mode

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$();
Enter fullscreen mode Exit fullscreen mode
  • you can write to it:
// will set `newTitle` to `hello`
state$({
  ...state$(),
  newTitle: "hello",
}); 
Enter fullscreen mode Exit fullscreen mode
  • you can subscribe to it:
useMlynEffect(() => {
  // will log the `state$` value every time it's updated
  console.log(state$());
});
Enter fullscreen mode Exit fullscreen mode

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: [] }
Enter fullscreen mode Exit fullscreen mode

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$()); 
  });
};
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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: ""
  });
};
Enter fullscreen mode Exit fullscreen mode

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)
  ]);
};
Enter fullscreen mode Exit fullscreen mode

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>
  </>
);
Enter fullscreen mode Exit fullscreen mode

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}
/>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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;
  }),
});
Enter fullscreen mode Exit fullscreen mode

You probably noticed that one input is a checkbox toggle for boolean value:

<Mlyn.input type="checkbox" bindChecked={todo$.done} />
Enter fullscreen mode Exit fullscreen mode

bindChecked is similar to bindValue but it creates 2-way binding for a boolean subject value to input checked field.

Discussion (0)