I first heard about MobX during a meetup in 2017. The speaker seemed to be very passionate about it. But I soon found myself zoning out as he kept talking about all the jargon in MobX. Observables? Derivations? Reactions? Proxies? It all sounded so complicated.

Later, I tried to go through the docs to understand it better. It was a tough read but I got the idea that MobX could automatically detect which data is used by a component and re-render that component only when that data changed. Like with most libraries and tools I have come across in the React ecosystem, I filed MobX away in my brain with this description and didn't come back to it.

When I came across Valtio after my break from the web I immediately thought of MobX again. It seemed very similar to MobX but Valtio was now calling this approach "proxy state management". I was skeptical, however. The promise of automatically optimizing a component's re-rendering behavior sounded too good to be true. So in this monthly post, I am going to experiment with "proxy state management" to see whether it lives up to this promise. I am going to refactor Timo to use both MobX and Valtio so that we can see what each of them is like in practice.

Hand drawn sketch of the MobX and Valtio logos

Why do we need proxy state management?

A common problem in React apps is components re-rendering when they are not supposed to. This causes apps to lag or freeze while a user interacts with them.

These unnecessary re-renders usually occur when several components use the same piece of state. For example in Timo, the user details and the authentication status is used by several components.

// A plain JS "enum" representing the authentication state of the user
const UserStatus = {
UNKNOWN: 'unknown',
AUTHENTICATED: 'authenticated',
UNAUTHENTICATED: 'unauthenticated'
};

// The piece of state that many components are interested in
const userState = {
status: UserStatus.AUTHENTICATED // Can be any of the states shown above
data: { // The user object that is returned from the API
id: 1,
username: 'bash',
avatar_character: 'b',
avatar_background: 'dark'
}
}
/* 
Components that use the user state
(Note - The way in which userState is provided to these components is omitted)
*/


// Original: https://github.com/bashlk/timo/blob/main/packages/common/contextualComponents/ProtectedRoute.jsx
const ProtectedRoute = ({ history, children }) => {
useEffect(() => {
// If the user is unauthenticated, this component redirects the user to the login page when they attempt to view
// a page that requires the user to be logged in.
if (userState.status === UserStatus.UNAUTHENTICATED) {
history.replace('./login');
}
});

if (userState.status === UserStatus.AUTHENTICATED) {
return children;
}
}

// Original: https://github.com/bashlk/timo/blob/main/packages/common/contextualComponents/TopBarWithUser.jsx
const TopBarWithUser = ({ history }) => (
<TopBar
// Uses the userState to show the avatar on the top left
avatar={{
character: userState.data?.avatar_character,
background: userState.data?.avatar_background
}}

onIconClick={() => history.push('./')}
onAvatarClick={() => history.push('./profile')}
/>

);

// Original: https://github.com/bashlk/timo/blob/main/packages/react/src/routes/Profile/sections/CustomizeUser.jsx
const CustomizeUser = () => {
const handleFormSubmit = (e) => {
const formData = new FormData(e.target);
const username = formData.get('username');
const avatarCharacter = formData.get('avatar-character');
const avatarBackground = formData.get('avatar-background');

// Function that calls the API to update the user details
updateUser({
id: userState.data?.id,
username,
avatar_character: avatarCharacter,
avatar_background: avatarBackground
});
}

// Displays the saved user details and allows the user to change them
// by editing the fields and pressing save
return (
<form action="" onSubmit={handleFormSubmit}>
<RadioGroup
name="avatar-background"
defaultValue={userState.data?.avatar_background}
/>

<Input
name="avatar-character"
type="text"
defaultValue={userState.data?.avatar_character}
/>

<Input
name="username"
type="text"
defaultValue={userState.data?.username}
/>

<Button type="submit">Save</Button>
</form>
);
}

State changes in React need to be immutable. If you mutate a state value in React, the components that use it will not re-render. So when the state is stored in an object, the entire object needs to be recreated whenever any of the properties within it change.

// Original: https://github.com/bashlk/timo/blob/main/packages/common/context/UserContextProvider.jsx
const [user, setUser] = useState({
status: UserStatus.UNKNOWN
});

const setAuthenticatedUser = (newUser) => {
// Javascript's spread syntax is used to create a new object
// so that the original user object is not mutated
setUser(user => ({ ...user, status: UserStatus.AUTHENTICATED, data: newUser }));
};

These immutable updates cause all the components that use the state object to re-render when any of its properties change. If you use props to pass the state object to the components, all the components that carry the state object will also re-render.

Hand drawn diagram of how state updates behave when a state object is shared using props and context

If you use a Flux-inspired state management library like Redux or Zustand, you can use selectors to specify the exact property that you need from the object. Components will then re-render only when the value returned by the selector has changed.

import { useSelector } from 'react-redux';

const getStatus = (state) => state.status;

// ProtectedRoute component adapted to Redux's useSelector
const ProtectedRoute = ({ history, children }) => {
// The useSelector hook triggers re-renders only when the return value of getStatus
// has changed
const userStatus = useSelector(getStatus);

useEffect(() => {
if (userStatus === UserStatus.UNAUTHENTICATED) {
history.replace('./login');
}
});

if (userStatus === UserStatus.AUTHENTICATED) {
return children;
}
};

However, these selectors need to be manually defined and it is easy to create selector functions that accidentally trigger more re-renders. For example, let's write a selector for the TopBarWithUser component that needs the avatar properties. Your first thought might be to write something like this.

import { useSelector } from 'react-redux';

const getUserData = (state) => state.data;

const TopBarWithUser = ({ history }) => {
const userData = useSelector(getUserData);

return (
<TopBar
avatar={{
character: userData?.avatar_character,
background: userData?.avatar_background
}}

onIconClick={() => history.push('./')}
onAvatarClick={() => history.push('./profile')}
/>

);
};

But this selector will trigger re-renders whenever any property in the state changes. This is because the reference returned by the selector to the data object changes whenever the state is immutably updated. To solve this problem, we need to write specific selectors that target each property individually or use memoized selectors such as the ones created using reselect.

import { useSelector } from 'react-redux';

const getAvatarCharacter = (state) => state.data.avatar_character;
const getAvatarBackground = (state) => state.data.avatar_background;

const TopBarWithUser = ({ history }) => {
// These selectors will only trigger re-renders when the avatar properties change
const character = useSelector(getAvatarCharacter);
const background = useSelector(getAvatarBackground);

return (
<TopBar
avatar={{ character, background }}
onIconClick={() => history.push('./')}
onAvatarClick={() => history.push('./profile')}
/>

);
};

As you can see, it is tricky to get components to re-render only when the specific properties they use change. This is where proxy state management libraries such as MobX and Valtio come in. By using Javascript proxies, these libraries can detect which properties are used by a component. Then, at least in theory, they are able to re-render components only when those properties change.

Let's see what these libraries are really like by putting them into Timo.

Refactoring Timo to use MobX

MobX is one of the oldest state management libraries. It was released around the same time as Redux in 2015 and while Redux changed a lot after the arrival of React Hooks, it seems that MobX has stuck to its original pattern with its heavy use of Javascript classes.

The MobX docs guide us to store the state as class members and to mark those members as "observable" by calling a helper function in the constructor. Following this pattern, Timo's user state looks like this.

import { makeAutoObservable } from 'mobx';

class User {
status = UserStatus.UNKNOWN;
data = {
id: null,
username: null,
avatar_character: null,
avatar_background: null
};
error = null;

constructor() {
// This function automatically marks class members as observable
makeAutoObservable(this);
}
}

Next, an instance of this class has to be created. This instance should be used wherever the state needs to be read or updated. The docs mention that the instance could be shared across components using props, React context or as a global variable. I didn't like any of those options and after a flashback to my university days of OOP and Java, I created a singleton to share the instance.

class UserSingleton {
// # marks it as a private property
static #instance = new User();

static get instance() {
return this.#instance;
}
}

I haven't worked with Javascript classes in years since modern React favors functional components over trusty old class components. But as I got back to them, I found that several new features have landed in Javascript for working with classes such as private properties, getters and setters. I also ran into old nuisances such as this pointing to different things depending on how a function is invoked. But once I got through all this, using the state in the User class was relatively simple.

import { observer } from 'mobx-react-lite';
import UserSingleton from '../store/User';

// Components that use observables must be wrapped by the observer HOC
const TopBarWithUser = observer(({ history }) => (
<TopBar
avatar={{
// The observables can be read directly from the instance of the User
// and this component will automatically re-render whenever these
// properties change
character: UserSingleton.instance.data?.avatar_character,
background: UserSingleton.instance.data?.avatar_background
}}

onIconClick={() => history.push('./')}
onAvatarClick={() => history.push('./profile')}
/>

));

Changing the state in the User class requires a method to be added to it and this method can mutate the observable class members. In MobX lingo, this is called an action and makeAutoObservable automatically marks methods in a class as actions.

class User {
...

setUser = (user) => {
// Simply mutate the members to change the state
// and trigger components to re-render
this.status = UserStatus.AUTHENTICATED;
this.data = user;
}
}

These actions can then be called through the instance.

import UserSingleton from '../../store/User';

// React component
const Login = () => {
const handleSuccess = (response) => {
UserSingleton.instance.setUser(response);
history.replace('./');
}

...
}

Once I refactored Timo to use the UserSingleton, the most noticeable re-rendering issues disappeared. But the avatar in the top bar was still re-rendering when other properties changed.

Recording of the browser showing re-renders on Timo's top bar when the username is changed
In theory, this shouldn't happen

MobX's gotchas

Don't reassign observables

As it turns out, the avatar was re-rendering because the data observable was being reassigned every time the user details were updated. Instead, it should have been mutated so that the existing observable is reused.

class User {
...

setUser = (user) => {
this.status = UserStatus.AUTHENTICATED;

// Don't reassign the observable
// this.data = user; ❌

// Mutate it instead ✅
this.data.id = user.id;
this.data.username = user.username;
this.data.avatar_character = user.avatar_character;
this.data.avatar_background = user.avatar_background;
}
}

Now the avatar re-renders only when the avatar properties change.

Recording of the browser showing that Timo's top bar only re-render when the avatar properties are changed
That's more like it

After years of writing immutable code for React, it is tricky to break the habit and write mutable code instead. However, I soon found that I had to break more habits to get the most out of MobX.

Be careful when dereferencing

I often use convenience variables in my React components when working with nested paths.

const CustomizeUser = () => {
// Reference userState.data in userData
// so that it doesn't have to be typed out all the time
const userData = userState.data;

const [avatar, setAvatar] = useState({
character: userData.avatar_character,
background: userData.avatar_background
});

return (
<Avatar
character={avatar.character}
background={avatar.background}
/>

)
}

But if I did this while using MobX, the component would re-render whenever any property in userState.data changed since MobX will track that the entire userState.data property is being read in the component.

The solution is to reference the values directly.

import { observer } from 'mobx-react-lite';
import UserSingleton from '../../../store/User';

const CustomizeUser = observer(() => {
const [avatar, setAvatar] = useState({
// Access the values directly so that MobX correctly tracks
// that only these values are being used in this component
character: UserSingleton.instance.data?.avatar_character,
background: UserSingleton.instance.data?.avatar_background
});

return (
<Avatar
character={avatar.character}
background={avatar.background}
/>

)
})

Large package size

The last thing that caught me off guard was the size of the core MobX library. It ended up being the 2nd largest dependency in Timo after the react-dom package and it is several times larger than the other state management libraries that I have looked at so far. I have a feeling that something is ending up in the final mobx npm package that should not be there.

Module treemap for the MobX version of Timo
mobx: 124.64KB (29.81%), mobx-react-lite: 10.13KB (2.42%)

You can also find the MobX version of Timo in the @timo/mobx package in the repo.

Refactoring Timo to use Valtio

I found Valtio nicer to use with modern React since it uses hooks instead of HOCs for interacting with the proxy store. I also found Valtio's docs much easier to digest.

To get started with Valtio, first, the state needs to be made into a proxy,

import { proxy } from 'valtio';

const userStore = proxy({
status: UserStatus.UNKNOWN,
data: {},
error: null
});

export default userStore;

and then it can be used in a component by using the useSnapshot hook.

import { useSnapshot } from 'valtio';
import userStore from '../../store/userStore';

const TopBarWithUser = ({ history }) => {
const user = useSnapshot(userStore);
return (
<TopBar
avatar={{
// When these properties change, the useSnapshot hook
// will make this component re-render
character: user?.data?.avatar_character,
background: user?.data?.avatar_background
}}

onIconClick={() => history.push('./')}
onAvatarClick={() => history.push('./profile')}
/>

);
};

The state can be changed by mutating the proxy,

// Like MobX, Valtio also refers to functions that mutate the state as actions.
export const setUser = (user) => {
userStore.status = UserStatus.AUTHENTICATED;
userStore.data = user;
};

and it can also be read directly from the proxy.

// Original: https://github.com/bashlk/timo/blob/main/packages/valtio/src/routes/Profile/sections/ChangePassword.jsx
import userStore from '../../../../store/userStore';

const ChangePassword = () => {
const handlePasswordFormSubmit = (e) => {
...

// Function that calls the API to update the password
updatePasswordM({
// The username is read from the proxy as this line is executed so it is always up to date
username: userStore?.data?.username,
password,
newPassword
});
};

...
}

Valtio has the same gotchas as MobX around re-assigning objects in the proxy and dereferencing properties. But its package size is much more reasonable.

Module treemap for the Valtio version of Timo
valtio: 9.36KB (3.08%), proxy-compare: 13.66KB (4.49%)

You can also find the Valtio version of Timo in the @timo/valtio package in the repo.

Summary

When I started this post, I was skeptical whether MobX and Valtio were really capable of re-rendering components only when the data they used changed. Now I can say that they really can.

Sure, there are some surprising gotchas around re-assigning and dereferencing. But addressing these gotchas is easier than the work that other libraries ask from us. (e.g. writing selectors, switching to a flatter state structure)

Based on my brief experience, I would recommend Valtio over MobX. It is nicer to use alongside functional React components thanks to its hooks and it provides the same functionality as MobX in a much smaller package. I would also recommend Valtio as a state management library because it is quite straightforward to use and the gotchas are easy to mend. It is an especially good fit for interactive editors and any React apps that have a large, frequently updated state tree.

With that, we have now covered all of the state management libraries in the Poimandres collective - Zustand, Jotai and Valtio. Refactoring Timo to use all of these libraries has helped me see state management in a new light and in the next monthly post, let's take a look at Xstate and state machines before this series about state management to a close.

Prabashwara Seneviratne (bash)

Written by

Prabashwara Seneviratne (bash)

Lead frontend developer