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.
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 />
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
});
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 twoaction
"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);
}),
});
We instantiate the store by using the 'addTodo' method we created:
store.addTodo({ id: Math.random(), title: "first", finished: false });
...
The imports:
import { observer } from "mobx-react-lite";
import store from './mobx-store';
import clsx from "clsx";
import "./index.css";
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>
</>
);
});
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>
);
};
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>
);
});
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>;
});
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} />
</>
)
};
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>
);
};
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>
);
}
and the todolist rendering identical:
const TodoListView = ({ todoList, onhandleToggle }) => {
return (
<ul>
{todoList &&
todoList.map((todo) => (
<TodoView todo={todo} key={todo.id} onToggle={onhandleToggle} />
))}
</ul>
);
};
function TodosCount({ count }) {
return <h3>State lift: UnFinished todos count: {count}</h3>;
}
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>
);
});
and finally:
ReactDOM.render(
<div>
<AppMobx />
<AppStateLift />
</div>,
document.getElementById("app")
);
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.
Thanks for reading!
Top comments (0)