A tiny, deeply reactive React hook for MobX that tracks exactly which properties
your component reads and only re-renders when those properties change. A drop-in
alternative to wrapping components with observer
.
- No HOCs or decorators
- Works with nested objects/arrays
- Minimal boilerplate and great ergonomics
npm install use-observable-mobx
# or
yarn add use-observable-mobx
# or
pnpm add use-observable-mobx
Requirements:
- React 18+ (uses
useSyncExternalStore
) - MobX 6+
- Create a MobX store (standard MobX patterns work)
import { makeAutoObservable } from "mobx";
class Store {
counter = 0;
items = [{ id: 1, text: "Item 1" }];
constructor() {
makeAutoObservable(this);
}
increment() {
this.counter++;
}
addItem(text: string) {
this.items.push({ id: this.items.length + 1, text });
}
}
export const store = new Store();
- Use
useObservable
in your components and just read what you need
import { store } from "./store";
import { useState } from "react";
import { useObservable } from "use-observable-mobx";
export const Counter = () => {
const { counter, increment } = useObservable(store); // reads only these two
return (
<div>
<p>Count: {counter}</p>
<button onClick={increment}>Increment</button>
</div>
);
};
export const ItemList = () => {
const { items, addItem } = useObservable(store);
const [text, setText] = useState("");
return (
<div>
<ul>
{items.map((item) => (
<li key={item.id}>{item.text}</li>
))}
</ul>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button
onClick={() => {
addItem(text);
setText("");
}}
>
Add Item
</button>
</div>
);
};
That’s it. The component re-renders only when the properties it read during render change.
Tip: You can compose a convenience hook if you prefer.
// Avoid re-importing the store everywhere
export const useStore = () => useObservable(store);
- Deeply reactive: If you read
todos[0].title
, only changes to that path trigger a re-render. - Zero ceremony: No HOC, no decorators, no special observers. Just a hook.
- Scales well: Components naturally subscribe to what they actually use.
-
useObservable<T extends object>(store: T): T
- Returns a reactive proxy to your MobX store.
- During render, it tracks which properties you access and subscribes only to those.
- Only those accessed properties will cause re-renders when they change.
-
useObservable.unwrap<T>(value: T): T
- Unwraps a reactive proxy produced by
useObservable
back to the original MobX object. - Safe to call with non-proxies (returns the value unchanged).
- Alias of
getOriginal
.
- Unwraps a reactive proxy produced by
-
getOriginal<T>(value: T): T
- Same as
useObservable.unwrap
. Provided as a named export for convenience.
- Same as
-
isReactiveProxy<T>(value: T): boolean
- Returns
true
ifvalue
is a reactive proxy created byuseObservable
.
- Returns
Notes:
- Tracking happens only during render. Reading properties inside event handlers or effects will not subscribe to changes of those properties.
- You should not store the reactive proxy in MobX state or React context. Use
unwrap
(orgetOriginal
) when passing values to places that should keep the original reference identity.
When passing MobX objects through React Context, keep the original object identity stable by unwrapping before providing. Then wrap again where you consume.
import { PropsWithChildren, createContext, useContext } from "react";
import { useObservable } from "use-observable-mobx";
type Item = { id: number; text: string };
const ItemContext = createContext<Item | null>(null);
export const ItemProvider = ({
item,
children,
}: PropsWithChildren<{ item: Item }>) => (
<ItemContext.Provider value={useObservable.unwrap(item)}>
{children}
</ItemContext.Provider>
);
export const useItem = () => {
const item = useContext(ItemContext);
if (!item) throw new Error("useItem must be used within <ItemProvider>");
return useObservable(item);
};
const ItemView = () => {
const item = useItem(); // reactive proxy
return <li>{item.text}</li>;
};
import { isReactiveProxy, useObservable } from "use-observable-mobx";
const Example = ({ myStore }: { myStore: object }) => {
const store = useObservable(myStore);
console.log(isReactiveProxy(store)); // true
console.log(isReactiveProxy({})); // false
return null;
};
You generally don’t need observer
around components that use useObservable
.
If you already have a codebase with observer
, you can:
- Gradually migrate to
useObservable
, or - Use both, although it’s redundant. Prefer one approach per component.
Because tracking only happens during render:
- It’s safe to read any values inside event handlers without subscribing.
- To subscribe to derived/computed values, read them during render (e.g.,
{store.fullName}
).
- On render, the proxy tracks every property you read (deep paths included).
- It subscribes to the relevant MobX observables for those paths.
- When any of those observed properties change,
useSyncExternalStore
triggers a re-render.
This mirrors the mental model: “re-render me when the things I actually used change.”
-
Do I still need to wrap components with
observer
?- No.
useObservable
handles the reactivity for you. Use it directly inside components.
- No.
-
Will reading inside callbacks subscribe my component?
- No. Only property reads during render are tracked.
-
Can I put the reactive proxy into context or store it in MobX state?
- Prefer passing/storing the original object. Use
useObservable.unwrap
(orgetOriginal
) when putting values into context or other long-lived containers.
- Prefer passing/storing the original object. Use
-
Is it TypeScript friendly?
- Yes. The return type matches your input type; getters and methods are fully typed.
-
How do I minimize re-renders?
- Only read what you need during render. If you read fewer properties, you’ll subscribe to fewer things.
- Discussion on deprecating
useObserver
in MobX - Valtio’s pattern of tracking accessed properties
This library borrows ideas from both, adapting them for a simple, hook-only MobX experience.
- Valtio: for the deeply reactive, access-tracking approach which this library borrows from.
- MobX: for the
useObserver
which this hook borrows from extensively.
Thanks to Gavel for sponsoring the initial development.
MIT