Reactive primitives for valtio — adds, computed, effect, and batch to enable fine-grained reactivity outside of React.
valtio's reactive capabilities are primarily designed for React via useSnapshot and only has limited support for computed values. valtio-reactive was made to fill those gaps while keeping valtio lean and fast.
- Run side effects when specific properties change (not just any change)
- Create derived/computed state that automatically updates
- Batch multiple updates into a single reaction
See the original discussion for more context.
npm install valtio valtio-reactiveThis runs the first function (fn) immediately and re-runs it whenever any of the properties that are accessed in that function change. Only the properties are actually read during execution are tracked — changes to unread properties won't trigger re-runs. It returns a dispose function that will run the cleanup function when called.
import { proxy } from 'valtio/vanilla';
import { effect } from 'valtio-reactive';
const state = proxy({
count: 0,
unrelated: 'hello'
user: {
settings: {
theme: 'light' //
},
name: 'Bob'
},
})
const dispose = effect(
() => {
console.log('count is: ', state.count)
console.log('theme is: ', state.user.settings.theme)
},
() => {
// optional cleanup function
console.log('cleaning up')
}
)
// immediately logs:
// "count is: 0"
// "theme is: light'
state.count++
// logs:
// "count is: 1"
// "theme is: light"
state.unrelated = 'world' // nothing happens when this property is changed because it wasn't accessed
state.user.name = 'Robert' // nothing happens
state.user.settings.theme = 'dark'
// logs:
// "count is: 1"
// "theme is: dark"
dispose()
// logs "cleaning up"Batches multiple state changes so that effects only react once after all changes complete. Returns the value returned by fn.
import { proxy } from 'valtio/vanilla';
import { batch, effect } from 'valtio-reactive';
const state = proxy({ count: 0 });
effect(() => {
console.log('count:', state.count);
});
// Logs: "count: 0"
batch(() => {
state.count++;
state.count++;
state.count++;
});
// Logs: "count: 3" (only once, not three times)Creates a proxy object with computed/derived properties. Each property is defined as a getter function that automatically re-runs when its dependencies change.
import { proxy } from 'valtio/vanilla';
import { computed } from 'valtio-reactive';
const state = proxy({ count: 1 });
const derived = computed({
double: () => state.count * 2,
quadruple: () => state.count * 4,
});
console.log(derived.double); // 2
console.log(derived.quadruple); // 4
state.count = 5;
console.log(derived.double); // 10
console.log(derived.quadruple); // 20The returned object is itself a valtio proxy, so you can use it with effect, useSnapshot, or any other valtio utility.
Note: Specific behavior computed() when mutating proxied array
When a computed() getter depends on a proxied array, in-place mutations like splice/shift/pop can briefly expose empty/undefined during the same tick. If you iterate the array with for of/find/findIndex , etc. in computed(), wrap these mutations in batch(() => ...) to keep derived reads consistent.
import { proxy } from 'valtio/vanilla';
import { computed, batch } from 'valtio-reactive';
const selectedId = 1;
const state = proxy({ list: [{ id: 1 }, { id: 2 }, { id: 3 }] });
const derived = computed({
selected: () => state.list.find((item) => item.id === selectedId),
});
console.log(derived.selected); // {id: 1}
// Bad: in derived.selected --> Will throw an error "TypeError: Cannot read properties of undefined (reading 'id')"
state.list.splice(0, 1);
// Good
batch(() => state.list.splice(0, 1));
console.log(derived.selected); // undefinedWhile these primitives are framework-agnostic, they integrate seamlessly with valtio's React bindings:
import { proxy, useSnapshot } from 'valtio';
import { effect, computed } from 'valtio-reactive';
const state = proxy({ count: 0 });
// Computed values work with useSnapshot
const derived = computed({
double: () => state.count * 2,
});
// Side effects outside of React
effect(() => {
console.log('Count changed:', state.count);
});
function Counter() {
const snap = useSnapshot(state);
const derivedSnap = useSnapshot(derived);
return (
<div>
<p>Count: {snap.count}</p>
<p>Double: {derivedSnap.double}</p>
<button onClick={() => state.count++}>+1</button>
</div>
);
}All exports are fully typed. The computed function infers types from your getter functions:
const state = proxy({ count: 1, name: 'test' });
const derived = computed({
double: () => state.count * 2, // inferred as number
message: () => `Hello ${state.name}`, // inferred as string
});
derived.double; // number
derived.message; // stringMIT