Fine-grained subscriptions to Valtio
proxies with automatic re-tracking on structural changes.
- The Problem
- The Solution
- Installation
- Usage
- API Reference
- How It Works
- Performance
- Comparison with useSnapshot
- Requirements
- License
- Contributing
Valtio's useSnapshot
hook has three significant limitations that make it difficult to work with in real-world applications:
useSnapshot
doesn't accept nullable proxies, requiring verbose workarounds:
const state = proxy({ user: null });
// ❌ This throws when user is null
function UserProfile() {
const user = useSnapshot(state.user); // Error!
return <div>{user.name}</div>;
}
Working with deeply nested optional structures requires complex null checks that are hard to optimize:
const state = proxy<{
user?: {
profile?: {
settings?: {
theme: "dark" | "light";
};
};
};
}>({});
// ❌ Verbose and error-prone
function ThemeDisplay() {
const snapshot = useSnapshot(state);
const theme = snapshot?.user?.profile?.settings?.theme;
return <div>Theme: {theme}</div>;
}
When the structure of an object changes, components subscribing to nested properties don't re-render:
const state = proxy({ user: { name: "John", age: 30 } });
function UserName() {
// Subscribes to the current user object
const user = useSnapshot(state.user);
return <div>{user.name}</div>;
}
// Later in your code...
state.user = { name: "Jane", age: 25 }; // Replace entire object
// ❌ Problem: UserName component still shows 'John'
// It's subscribed to the OLD user object, not the NEW one
This is a fundamental limitation because useSnapshot
creates subscriptions when the component first renders, and those subscriptions don't automatically update when parent objects are replaced.
useTrackedSnapshot
solves all three problems with a selector-based approach:
import { proxy } from "valtio";
import { useTrackedSnapshot } from "valtio-select";
const state = proxy({ user: null as { name: string } | null });
function UserProfile() {
// ✅ Works with nullable state
const name = useTrackedSnapshot(state, (s) => s.user?.name ?? "Guest");
return <div>Welcome, {name}!</div>;
}
Selectors handle null/undefined gracefully with optional chaining:
// ✅ Clean and safe
const userName = useTrackedSnapshot(state, (s) => s.user?.name);
Extract exactly what you need without intermediate snapshots:
// ✅ Direct access to deeply nested values
const theme = useTrackedSnapshot(
state,
(s) => s.data?.user?.profile?.settings?.theme ?? "light"
);
Automatically re-subscribes when structure changes:
function UserName() {
// ✅ Always subscribes to current user, even if replaced
const name = useTrackedSnapshot(state, (s) => s.user.name);
return <div>{name}</div>;
}
// When you replace the user object...
state.user = { name: "Jane", age: 25 };
// ✅ Component automatically re-renders with 'Jane'
How it works: When any tracked property changes, useTrackedSnapshot
:
- Re-runs your selector to see what's currently accessed
- Rebuilds subscriptions to match the current structure
- Notifies React to re-render
This ensures your subscriptions always match your current state structure.
# npm
npm install valtio-select valtio
# yarn
yarn add valtio-select valtio
# pnpm
pnpm add valtio-select valtio
# bun
bun add valtio-select valtio
import { proxy } from "valtio";
import { useTrackedSnapshot } from "valtio-select";
// Create your Valtio state
const state = proxy({
count: 0,
user: { name: "John", age: 30 },
});
function Counter() {
// Only subscribes to 'count'
const count = useTrackedSnapshot(state, (s) => s.count);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => state.count++}>Increment</button>
</div>
);
}
function UserName() {
// Only subscribes to 'user.name'
const name = useTrackedSnapshot(state, (s) => s.user.name);
return <div>User: {name}</div>;
}
const state = proxy({
app: {
settings: {
theme: {
mode: "dark" as "dark" | "light",
primaryColor: "#007bff",
},
},
},
});
function ThemeToggle() {
const mode = useTrackedSnapshot(state, (s) => s.app.settings.theme.mode);
const toggleTheme = () => {
state.app.settings.theme.mode = mode === "dark" ? "light" : "dark";
};
return <button onClick={toggleTheme}>Current theme: {mode}</button>;
}
const state = proxy({
currentUser: null as {
id: number;
profile: { name: string; email: string };
} | null,
});
function UserProfile() {
const profile = useTrackedSnapshot(state, (s) => s.currentUser?.profile);
if (!profile) {
return <div>Please log in</div>;
}
return (
<div>
<h2>{profile.name}</h2>
<p>{profile.email}</p>
</div>
);
}
// When user logs in, component automatically updates
function login() {
state.currentUser = {
id: 1,
profile: { name: "John", email: "john@example.com" },
};
}
// When user logs out, component automatically updates
function logout() {
state.currentUser = null;
}
const state = proxy({
todos: [
{ id: 1, text: "Buy milk", completed: false },
{ id: 2, text: "Walk dog", completed: true },
],
});
function TodoStats() {
const todos = useTrackedSnapshot(state, (s) => s.todos);
const stats = useMemo(
() => ({
total: todos.length,
completed: todos.filter((t) => t.completed).length,
active: todos.filter((t) => !t.completed).length,
}),
[todos]
);
return (
<div>
<p>Total: {stats.total}</p>
<p>Completed: {stats.completed}</p>
<p>Active: {stats.active}</p>
</div>
);
}
const state = proxy({
view: "grid" as "grid" | "list",
gridSettings: { columns: 3 },
listSettings: { density: "comfortable" },
});
function ViewSettings() {
const settings = useTrackedSnapshot(state, (s) =>
s.view === "grid" ? s.gridSettings : s.listSettings
);
// TypeScript knows: settings is { columns: number } | { density: string }
return <div>{JSON.stringify(settings)}</div>;
}
Creates a fine-grained subscription to a Valtio proxy using a selector function.
-
proxy:
T extends object
- The Valtio proxy object to track
- Must be an object (not primitive, null, or undefined)
- Can be any Valtio proxy created with
proxy()
-
getSnapshot:
(proxy: T) => R
- A function that extracts the desired data from the proxy
- Receives the proxy as its parameter
- Should return any value (primitive, object, array, etc.)
- Should be a stable reference (wrapped in
useCallback
if it has dependencies)
- R - The result of
getSnapshot
, re-computed when tracked properties change
- Initial Render: Calls
getSnapshot
with a tracking proxy that records all property accesses - Subscriptions: Creates fine-grained subscriptions to only the accessed properties
- Updates: When any subscribed property changes:
- Re-runs
getSnapshot
with the real proxy - Rebuilds subscriptions to handle structural changes
- Triggers React re-render if the result changed
- Re-runs
- Cleanup: Automatically unsubscribes when component unmounts
-
Selector Stability: For best performance, memoize your selector function:
const selector = useCallback((s) => s.user.name, []); const name = useTrackedSnapshot(state, selector);
-
Tracked Properties: Only properties accessed during
getSnapshot
are tracked. Conditional access means conditional tracking:// If condition is true, tracks 'a'. If false, tracks 'b' const value = useTrackedSnapshot(state, (s) => (condition ? s.a : s.b));
-
Structural Changes: When an object is replaced, subscriptions automatically rebuild:
// Component initially subscribes to original user object const name = useTrackedSnapshot(state, (s) => s.user.name); // When user is replaced, component re-subscribes to new user state.user = { name: "Jane" };
The core, non-React subscription function that powers useTrackedSnapshot
. It provides the same fine-grained, auto-re-tracking subscription logic for use in any JavaScript/TypeScript environment.
This is useful for integrating tracked state with non-React libraries, vanilla TypeScript logic, or for building your own custom hooks and abstractions.
-
proxy:
T extends object
- The Valtio proxy object to track.
-
getter:
(proxy: T) => unknown
- A function that accesses properties on the proxy. The properties accessed within this function will be tracked.
-
callback:
() => void
- The function to be called whenever a tracked property changes.
- unsubscribe:
() => void
- A function that cleans up and removes the subscription.
import { proxy } from "valtio";
import { subscribeTracked } from "valtio-select";
const state = proxy({ count: 0, other: "data" });
console.log("Subscribing to state.count");
const unsubscribe = subscribeTracked(
state,
(s) => s.count, // The getter tracks this property
() => console.log("Count changed!", state.count)
);
// This will trigger the callback
state.count++; // Logs: Count changed! 1
// This will NOT trigger the callback because 'other' is not tracked
state.other = "new data";
// Clean up the subscription
unsubscribe();
console.log("Unsubscribed.");
// This will no longer trigger the callback
state.count++;
useTrackedSnapshot
uses a multi-layered tracking mechanism:
During the initial render and after structural changes, the selector runs with a special tracking proxy that records every property access:
const trackingProxy = new Proxy(state, {
get(target, prop) {
recordAccess(target, prop); // Record this access
const value = target[prop];
// Recursively wrap nested objects
if (value && typeof value === "object") {
return new Proxy(value, handler);
}
return value;
},
});
After tracking, the hook creates individual subscriptions for each accessed property using Valtio's subscribeKey
:
// If selector accessed state.user.name and state.user.email
subscribeKey(state.user, "name", handleChange);
subscribeKey(state.user, "email", handleChange);
When any subscribed property changes, the hook:
- Cleans up old subscriptions
- Re-runs the selector with the tracking proxy
- Creates new subscriptions based on current accesses
- Notifies React to re-render
This ensures subscriptions always match the current structure, solving the structural change problem.
Uses React's useSyncExternalStore
for:
- Proper React 18 concurrent mode support
- Automatic server-side rendering compatibility
- Correct timing of subscriptions and updates
Only accessed properties are subscribed to, minimizing overhead:
// Subscribes to 3 properties: state.user, user.profile, profile.name
const name = useTrackedSnapshot(state, (s) => s.user.profile.name);
// Subscribes to 2 properties: state.count, state.total
const percentage = useTrackedSnapshot(state, (s) => (s.count / s.total) * 100);
Components only re-render when their specific data changes:
const state = proxy({ a: 1, b: 2, c: 3 });
// Component 1 only re-renders when 'a' changes
function CompA() {
const a = useTrackedSnapshot(state, (s) => s.a);
return <div>{a}</div>;
}
// Component 2 only re-renders when 'b' changes
function CompB() {
const b = useTrackedSnapshot(state, (s) => s.b);
return <div>{b}</div>;
}
// Changing 'c' doesn't re-render either component
state.c = 4;
Subscriptions are automatically cleaned up:
- When the component unmounts
- When the selector function changes
- During re-tracking after structural changes
Feature | useSnapshot | useTrackedSnapshot |
---|---|---|
Nullable proxies | ❌ Throws error | ✅ Works with optional chaining |
Deep nesting | ✅ Direct selector access | |
Structural changes | ❌ Stale subscriptions | ✅ Auto re-tracking |
Fine-grained updates | ✅ Full snapshot | ✅ Selector-based |
Type inference | ✅ Full | ✅ Full |
API complexity | Simple | Simple |
- You need a complete snapshot of an object
- Your component uses many properties from the same object
- State structure is stable and never replaced
- Working with nullable/optional state
- Accessing deeply nested properties
- Objects are frequently replaced (structural changes)
- Need maximum re-render optimization
- Extracting computed/transformed values
Full TypeScript support with complete type inference:
const state = proxy({
count: 0,
user: { name: "John", age: 30 } as { name: string; age: number } | null,
items: [1, 2, 3],
});
// Type: number
const count = useTrackedSnapshot(state, (s) => s.count);
// Type: string | undefined
const name = useTrackedSnapshot(state, (s) => s.user?.name);
// Type: number[]
const doubled = useTrackedSnapshot(state, (s) => s.items.map((x) => x * 2));
// Type: { total: number; average: number }
const stats = useTrackedSnapshot(state, (s) => ({
total: s.items.length,
average: s.items.reduce((a, b) => a + b, 0) / s.items.length,
}));
- React 18.0.0 or higher (uses
useSyncExternalStore
) - Valtio 1.0.0 or higher
Copyright © 2025 tszen • MIT license.
Contributions are welcome! Please read our contributing guide.
Documentation • Issues • NPM
Made with ❤️ for the React community