(Pronounced like machine.)
What if Jotai, Recoil, SolidJS's signal, and React Query are mixed together? That's Mesin.
- Build complex states with dynamic dependencies like spreadsheet's state.
- Track dependencies using signal like SolidJS.
- Computed stores used in multiple places are computed only once.
- No memory leak.
- Circular dependency can be handled.
- Dedupe and revalidate queries like React Query* but without dealing with keys.
npm install mesin
const users = store({
"user-1": {
name: "Foo",
friends: ["user-2"],
},
"user-2": {
name: "Bar",
friends: ["user-1"],
},
});
const user = compute((id: string) => {
users.select((allUsers) => allUsers[id]);
});
const userFriends = compute((id: string) => {
const currentUser = user(id).get();
const friends = currentUser?.friends?.map((friendId) => {
user(friendId).get();
});
return friends;
});
const User = ({ id }: { id: string }) => {
const currentUser = useStore(user(id));
const friends = useStore(userFriends(id));
if (!currentUser) {
return null;
}
return (
<div>
<h2>{currentUser.name}</h2>
<h3>Friends</h3>
<ul>
{friends?.map((friend) => (
<li>{friend.name}</li>
))}
</ul>
</div>
);
};
A writable primitive store.
const users = store({
"user-1": {
name: "Foo",
dateOfBirth: 2000,
},
...
});
Get all users:
const allUsers = computed((
8000
id: string) => {
return users.get();
});
Select a user:
const user = computed((id: string) => {
return users.select((all) => all[id]);
});
Note: The select callback should be cheap because it may be called every time there's a data change. The return value is used to check if the selected dependency has changed. Array filter should not be used in the select function because it always returns a different reference.
The store value can be updated from anywhere:
const addUser = (id: string, user: User) => {
const all = users.get();
users.set({ ...users, [id]: user });
};
From a computed store:
const user = computed((id: string) => {
const currentUser = users.select((all) => all[id]);
if (currentUser.dateOfBirth >= 2000) {
// Delete user
const newUsers = { ...users.get() };
delete newUsers[id];
users.set(newUsers);
return;
}
return currentUser;
});
From an effect:
effect(() => {
const newUsers = { ...users.get() };
let changed = false;
Object.entries(newUsers).forEach((id, user) => {
if (user.score < 0) {
delete all[id];
changed = true;
}
});
if (changed) {
users.set(newUsers);
}
});
Primitive store updates performed inside a reactive block (computed store or effect) are batched at the end of the compute cycle (after all computed stores and effects finished).
If a store is set multiple times in a the same write cycle, only the last set is called.
const count = store(0);
effect(() => {
const currentCount = count.get();
count.set(currentCount + 1); // Ignored
count.set(currentCount + 2);
});
Note: Setting a store value inside a reactive block is discouraged. If the same store is set from multiple reactive blocks, it could introduce a race condition.
A reactive store that is computed from primitive stores or other computed stores. The dependencies are tracked automatically. The callback must be synchronous. Calling myStore.get()
or myStore.select()
outside the synchronous block won't add the store as a dependency. A computed store also has get()
method to get the entire value and select()
method to get a subset of the value.
const userAge = compute((id: string) => {
const dateOfBirth = user(id).select((u) => u.dateOfBirth);
if (dateOfBirth === undefined) {
return;
}
new Date.getFullYear() - dateOfBirth;
});
When there's a circular dependency, get()
and select()
throw an error, and it should be catch.
const x = compute(() => {
try {
return x().get();
} catch {
return 0;
}
});
// x().get() === 0;
Computed stores are removed from the cache shortly after it has no subscriber.
A function that is called every time its current dependencies change.
effect(() => {
// This function is called every time users and orders change.
const allUsers = users.get();
const allOrders = orders.get();
console.log("users", allUsers);
console.log("orders", allOrders);
});
effect
can be used to sync a store with an external store, e.g local storage.
const storedSettings = localStorage.getItem("settings");
const initSettings = storedSettings
? JSON.parse(storedSettings)
: DEFAULT_SETTINGS;
const settings = store(initSettings);
let lastValueFromStorage = initSettings;
addEventListener("storage", (e) => {
if (e.key === "settings" && e.newValue) {
try {
const newValue = JSON.parse(e.newValue);
settings.set(newValue);
} catch {
const current = settings.get();
localStorage.setItem("settings", JSON.stringify(current));
}
}
});
effect(() => {
const current = settings.get();
if (current !== lastValueFromStorage) {
localStorage.setItem("settings", JSON.stringify(current));
}
});
A primitive store which is updated automatically with the return value of the loader. Initially a query is in a "pending" state until the loader
resolves. loader
is not a reactive block. So if you use other stores in the loader function, it won't get updated when the stores change.
const user = query((id: string) => {
return fetch(`/users/${id}`);
});
A query can be in one of these three states:
export interface QueryPending {
status: "pending";
}
export interface QueryError {
status: "error";
error: unknown;
}
export interface QueryFinished<T> {
status: "finished";
value: T;
}
export type QueryState<T> = QueryPending | QueryError | QueryFinished<T>;
A query is updated every opts.updateEvery
milliseconds when it has at least one subscriber. It's destroyed (removed from the cache) after it has no subscriber for opts.destroyAfter
milliseconds. If you use a query that has been destroyed, it will start from a "pending" state again.
A query value can be set manually:
user("user-1").set({
name: "Foo",
});
A query can be refreshed manually:
user("user-1").load();
Update multiple stores at once.
If you update multiple stores like this
const update = () => {
storeA
93C1
.set(1);
storeB.set(1);
};
A computed store or an effect that depends on storeA
and storeB
directly or indirectly will be recomputed twice.
You can use batch()
to not trigger multiple recomputes to subscribers.
const update = () => {
batch(() => {
storeA.set(1);
storeB.set(1);
});
};
If you call get()
after set()
, you'll get the old value because the update is deferred.
batch(() => {
const a = storeA.get(); // 1
storeA.set(a + 1);
storeA.get(); // Still 1
});
I love Jotai. It’s an improvement over Zustand, which I also loved. But it has some flaws which inspired me to create Mesin.
const filteredPostsAtom = atomFamily((param: { category: string; author: string }) => atom((get) => { ... }));
The good thing about atom family is that it caches the value. If we’re using the same atom family with the same parameter, it will be computed only once. But atom family has a memory leak issue. It creates an atom for every parameter we use and stores them in a map. The unused atoms never get removed from the map. Thus the map only grows as the application uses the atom family with different parameters.
We can remove the cache items manually based on the creation timestamp, but we don’t know which one is no longer being used.
The parameters are used as the keys for the map. If we're using object parameters, they usually have different object references. Thus it never gets the value from the cache, instead, it creates a new atom each time we use it
Jotai provides a workaround for this issue by allowing us to use a custom deep equal function to compare the parameter with the cache keys. The problem with this is that it runs the deep equal function for every cache key, or until it finds a match.
Mesin serializes the computed store parameters with a fast serializer. So we can use object parameters without scanning the cache keys.
const filteredPostsAtom = (category: string, author: string) => atom((get) => { ... })
With atom generator, we don’t have a memory leak issue because after it’s not being used (referenced) it’s automatically garbage-collected by the Javascript runtime. But we don’t get the benefit of using cache because every time we call filteredPostsAtom()
it generates a new atom. Thus if we use filteredPostsAtom
with the same parameter in multiple places (components or other computed atoms), Jotai will recompute the value multiple times.
Jotai supports async atoms. Most of the time we create an async atom because it (or its dependency) fetches some data. Often it also has dependencies. Every time the dependencies change we may end up fetching the same data.
Mesin has a query store that is meant for data fetching or other async stuff. I aspire to add some features of react query into it but without dealing with keys. Computed stores that depend on a query are still synchronous, thus they work more predictably. While Jotai’s async atoms may suffer from race conditions. For example, the previous computation may still run and add new dependencies.
The benefit of using an atomic state management system is that the dependency chain is dynamic. But it can create a dependency cycle. For example, in a spreadsheet application users may create a formula in column “A1” that references column “A2” (=SUM(A2,A3)
). While at the same time column “A2” is computed from column “A1” (=MAX(A1,A3)
).
With Jotai we may end up with infinite recursion until the application crashes. On the other hand, Mesin throws an error when it detects a dependency cycle. We only need to catch this error in computed stores that potentially create a dependency cycle.
We can think of Jotai’s atom as a key to value in a centralized store. The synchronization is done by the store. So if we want to use or set an atom value outside of the React lifecycle, we have to use the store API.
const myStore = createStore();
myStore.get(filteredPostsAtom);
Mesin’s stores manage the data directly. It has a manager that synchronizes the updates. But it’s an implementation detail that users don’t need to deal with. So we can get the value of a store outside of React components directly from the store itself.
filteredPosts.get();
Mesin automatically detects subscriptions using signal. So getting a store value from outside of the reactive block, e.g. in a setTimeout
callback, won’t add that store as a dependency for that computed store or effect.
Jotai uses a getter function to get the value and subscribe to an atom. We can pass it to a setTimeout
callback or an async function and it will add the atom that is called with as a dependency even after the computed atom has resolved.