DEV Community

NDREAN
NDREAN

Posted on • Updated on

A comparison of state management in React with Mobx vs State lifting

This post is an attempt to compare state management between Mobx and the standard state lifting method on a tiny example. It exposes a starter's viewpoint on how both methods can be used and focuses on not using classes since most if not all examples of code use classes. As we conclusion, we observe the rendering profiling which in this example is in favor of Mobx.

Alt Text

Cost of Mobx
The import cost is 45 kB.

Then it may be interesting to quote Mobx: "using observables inside React components adds value as soon as they are either 1) deep, 2) have computed values or 3) are shared with other observer components".

Let's work on the tiny example proposed by Mobx's todo list. This example handles a list of items where:

  • every item can be checked or not,
  • checking will change the style of the item (additional feature)
  • we can add a new item,
  • and we count the number of unchecked items.

We define 5 components: App, TodoListView, TodoView, NewTodo and the TodosCount. We also use the tiny library clsx for conditional class rendering.

<App/>
  |- <TodosCount />
  |- <NewTodo />
  |- <TodoListView />
           |- <TodoView />
Enter fullscreen mode Exit fullscreen mode

Mobx version

Debugging tool

We may want to enable the very useful debugging config to track errors (unneeded decorators, unwatched actions...) by using:

import { configure } from "mobx";
configure({
  enforceActions: "always",
  computedRequiresReaction: true,
  reactionRequiresObservable: true,
  observableRequiresReaction: true,
  disableErrorBoundaries: true
});
Enter fullscreen mode Exit fullscreen mode

For example, if you get a message like "Derivation observer is created/updated without reading any observable value", this means that some component is wrongly decorated with observer.
My strategy was basically to wrap every component with observer and then remove the warnings by putting action on events.

Domain store

We define our domain store, the list of todos. It is an object that will be proxied by the observable method. Here we remove the logic from the components and move it into the store.

This "store" contains:

  • the attribute todos=[], an array of objects in the form:

{ id: Math.random(), title: "first", finished: false }

  • three functions: a getter named "unfinished" that returns just a value, and two action "addTodo" and "toggle" that modifies the store.

The functions that mutate directly the state within the "store" are wrapped by an action. This is the big difference with pur React: we don't have to write pur functions where we use copies of the state to manipulate it.

Wrapping it with observable defines what Mobx should monitor.

# mobx-store.js
import { observable, action } from "mobx";

const store = observable({
  todos: [],
  get unfinished() {
    return this.todos.filter((todo) => todo.finished === false).length;
  },
  addTodo: action((todo) => store.todos.push(todo)),
  toggle: action((todoid) => {
    const id = store.todos.findIndex((todo) => todo.id === todoid);
    return (store.todos[id].finished = !store.todos[id].finished);
  }),
});
Enter fullscreen mode Exit fullscreen mode

We instantiate the store by using the 'addTodo' method we created:

store.addTodo({ id: Math.random(), title: "first", finished: false });
...
Enter fullscreen mode Exit fullscreen mode

The imports:

import { observer } from "mobx-react-lite";
import store from './mobx-store';
import clsx from "clsx";
import "./index.css";
Enter fullscreen mode Exit fullscreen mode

Decorate Components with observer

The arrow functions components will be proxied (or not) with the observer decorator to create a reactive context. The rule is: Mobx should only read observable within an observer component. The debug config helps to define which component should be proxied with observer.

With the closure/import, the store is available within each component. However, Mobx recommends to pass object references around as long as possible.

Then not everything should be handled by the store. Mobx encourages to use local state with React.useState whenever local state is needed.

This proxying makes the code cleaner compared to the state lifting technique where we have to explicitly pass down the references to the methods. It is also shorter compared to the useContext hook. We just use the methods defined in the "store" where needed.

This component uses observable values to render so we wrap it with observer. Also Mobx asks to wrap events with action as used here.

const TodoView = observer(({ todoList,todo }) => {
  const mystyle = clsx({ ischecked: todo.finished,
    notchecked: !todo.finished,
  });
  return (
    <>
      <li>
        <label htmlFor={todo.title} className={mystyle}>
          <input
            type="checkbox"
            id={todo.title}
            defaultChecked={todo.finished}
            onChange={action(() => todoList.toggle(todo.id))}
          />
          {todo.title}
        </label>
      </li>
    </>
  );
});
Enter fullscreen mode Exit fullscreen mode

The todo creation uses the method addTodo defined in the store. Note that the debug config asks to remove the decorator because the component doesn't use any value from the "store" to render. However, we modify the "store" on the submit event so again we have to use an action to wrap the event.

const NewTodo = ({todoList}) => {
  const [newTitle, setNewTitle] = React.useState("");
  return (
    <form
      onSubmit={action((e) => {
        e.preventDefault();
        todoList.addTodo({ title: newTitle, id: Math.random(), finished: false });
        setNewTitle("");
      })}
    >
      <input
        type="text"
        value={newTitle}
        onChange={(e) => setNewTitle(e.target.value)}
      />
      <input type="submit" value="New item" />
    </form>
  );
};
Enter fullscreen mode Exit fullscreen mode

The component that renders the list of todos is a classic map. This component obviously uses the values to render so needs to be wrapped:

const TodoListView = observer(({ todoList }) => {
  return (
    <ul>
      {todoList.todos && todoList.todos.map((todo) => <TodoView todo={todo} key={todo.id} todoList={todoList}/>)}
    </ul>
  );
});
Enter fullscreen mode Exit fullscreen mode

This component renders the count of unchecked todos and calls the 'unfinished' method defined in the store. Again the component uses a value to render so we wrap it.

const TodosCount = observer(({todoList) => {
  return <h3>Mobx: UnFinished todos count: {todoList.unfinished}</h3>;
});
Enter fullscreen mode Exit fullscreen mode

Mobx recommends to 'grab values from objects as late as possible': instead of passing store.todos, we pass the "store" object to the higher component "App" and cascade down. Since this component doesn't use the "store" values to render, there is no need to wrap with observer.

export default function AppMobx () {
  return(
   <>
    <TodosCount todoList={store}/> 
    <NewTodo todoList={store} />
    <TodoListView todoList={store} />
   </>
  )
};
Enter fullscreen mode Exit fullscreen mode

In conclusion, I just followed the debug config to eventually remove the warnings and this works.

State lifting method

With the method, the higher component App will handle state, namely the 'todos': it is an array of objects in the form {id:number, title:string, finished: boolean}. While with Mobx the actions are in the store and we just call them where needed, here the actions that modify state are defined in the higher component and we pass references along to the children.

For the component that renders each todo, we just use the reference to the function 'toggle':

const TodoView = ({ todo, onToggle }) => {
  const mystyle = clsx({
    ischecked: todo.finished,
    not checked: !todo.finished,
  });
  return (
    <li>
      <label htmlFor={todo.title} className={mystyle}>
        <input
          type="checkbox"
          id={todo.title}
          defaultChecked={todo.finished}
          onChange={() => onToggle(todo.id)}
        />
        {todo.title}
      </label>
    </li>
  );
};
Enter fullscreen mode Exit fullscreen mode

This todo creation is very similar:

function NewTodo({ onhandleAddTodo }) {
  const [newTitle, setNewTitle] = React.useState("");
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        onhandleAddTodo({ title: newTitle, id: Math.random(), finished: false });
        setNewTitle("");
      }}
    >
      <input
        type="text"
        value={newTitle}
        onChange={(e) => setNewTitle(e.target.value)}
      />
      <input type="submit" value="Submit" />
    </form>
  );
}

Enter fullscreen mode Exit fullscreen mode

and the todolist rendering identical:

const TodoListView = ({ todoList, onhandleToggle }) => {
  return (
      <ul>
        {todoList &&
          todoList.map((todo) => (
            <TodoView todo={todo} key={todo.id} onToggle={onhandleToggle} />
          ))}
      </ul>
  );
};
Enter fullscreen mode Exit fullscreen mode
function TodosCount({ count }) {
  return <h3>State lift: UnFinished todos count: {count}</h3>;
}
Enter fullscreen mode Exit fullscreen mode

The higher component holds state, the todos. All the actions that modify state are defined here.

const AppStateLift = React.memo(() => {
  const [todos, setTodos] = React.useState(initList);
  const [count, setCount] = React.useState(0);

  React.useEffect(() => {
    setCount(todos.filter((todo) => todo.finished === false).length);
  }, [todos]);

  function hanleToggle(id) {
    setTodos((previous) => {
      const foundId = previous.findIndex((todo) => todo.id === id);
      const todoAtFoundId = previous[foundId];
      const newTodos = [...previous];
      newTodos[foundId] = {
        ...todoAtFoundId,
        finished: !todoAtFoundId.finished,
      };
      return newTodos;
    });
  }

  function handleAddTodo(todo) {
    setTodos((previous) => [...previous, todo]);
  }

  return (
    <div>
      <TodosCount/>
      <NewTodo onhandleAddTodo={handleAddTodo} />
      <TodoListView
        todoList={todos}
        onhandleToggle={handleToggle}
      />
    </div>
  );
});
Enter fullscreen mode Exit fullscreen mode

and finally:

ReactDOM.render(
  <div>
    <AppMobx />
    <AppStateLift />
  </div>,
  document.getElementById("app")
);
Enter fullscreen mode Exit fullscreen mode

Conclusion

Now that we have the code, we can run the same sequence on each version and observe the profiling in the dev tools. I clicked successively on all checkboxes and entered a new todo. You may find a big difference: Mobx seems to memoize and render the minimum, compared to the React rendering. Here is the result.

Alt Text

Thanks for reading!

Discussion (0)