react-tracked, recoil, and zustand are three distinct approaches to solving state management in React applications. recoil introduces an atomic model where state is broken into small, independent units that components subscribe to granularly. zustand offers a minimalist, hook-based store pattern that relies on a single global store object with selective subscriptions. react-tracked leverages JavaScript Proxies to automatically track state usage and re-render components only when specific properties change, without requiring explicit selectors. Each library addresses the re-render optimization problem differently, trading off boilerplate, learning curve, and architectural flexibility.
When building complex React applications, managing state efficiently is critical for performance and maintainability. react-tracked, recoil, and zustand all solve the problem of global state, but they use fundamentally different mechanisms to decide when a component should re-render. Let's dive into how they work under the hood and when to use each one.
recoil breaks state into tiny, independent units called "atoms."
// recoil: Defining independent atoms
import { atom, useRecoilState } from 'recoil';
const countState = atom({ key: 'count', default: 0 });
const textState = atom({ key: 'text', default: '' });
function Counter() {
// Only re-renders when 'countState' changes
const [count, setCount] = useRecoilState(countState);
return <div>{count}</div>;
}
zustand uses a single store object that holds all state.
// zustand: Creating a unified store
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
text: '',
increment: () => set((state) => ({ count: state.count + 1 })),
}));
function Counter() {
// Must use a selector to avoid re-rendering on 'text' changes
const count = useStore((state) => state.count);
return <div>{count}</div>;
}
react-tracked wraps your state in a Proxy.
// react-tracked: Creating a tracked context
import { createContainer } from 'react-tracked';
const useValue = createContainer(() => {
const [state, setState] = useState({ count: 0, text: '' });
return { state, setState };
});
function Counter() {
const { state } = useValue();
// Automatically tracks 'state.count' usage
// Re-renders only when 'count' changes, not 'text'
return <div>{state.count}</div>;
}
The biggest difference between these libraries is how they prevent unnecessary re-renders.
recoil handles this at the source.
// recoil: Granular subscription is automatic
const userName = atom({ key: 'userName', default: 'Alice' });
const userAge = atom({ key: 'userAge', default: 30 });
// Component A only subscribes to name
function NameDisplay() {
const name = useRecoilValue(userName);
return <span>{name}</span>; // Won't re-render if age changes
}
zustand requires explicit selectors.
// zustand: Manual selection required
const useUserStore = create(() => ({ name: 'Alice', age: 30 }));
function NameDisplay() {
// Explicitly select 'name' to optimize
const name = useUserStore((state) => state.name);
return <span>{name}</span>; // Won't re-render if age changes
}
react-tracked automates tracking via Proxies.
state.count, it subscribes only to count.// react-tracked: Automatic dependency tracking
function NameDisplay() {
const { state } = useValue();
// Library detects access to 'state.name' automatically
return <span>{state.name}</span>; // Won't re-render if age changes
}
Complex apps often need state that is calculated from other state.
recoil has built-in "selectors" for derived state.
// recoil: Built-in derived state
const todoList = atom({ key: 'todoList', default: [] });
const doneCount = selector({
key: 'doneCount',
get: ({ get }) => {
const list = get(todoList);
return list.filter((item) => item.done).length;
},
});
function DoneCount() {
const count = useRecoilValue(doneCount); // Auto-updates when list changes
return <div>{count} done</div>;
}
zustand handles logic inside the store or with external tools.
zustand/middleware for persistence.useMemo in components.// zustand: Computed state in store
const useStore = create((set, get) => ({
todos: [],
getDoneCount: () => get().todos.filter((t) => t.done).length,
addTodo: (text) => set((state) => ({
todos: [...state.todos, { text, done: false }]
})),
}));
function DoneCount() {
// Call function or select computed value
const count = useStore((state) => state.getDoneCount());
return <div>{count} done</div>;
}
react-tracked treats derived state as normal React logic.
// react-tracked: Derived state via standard logic
function DoneCount() {
const { state } = useValue();
// Standard JS calculation; component re-renders if 'todos' changes
const count = state.todos.filter((t) => t.done).length;
return <div>{count} done</div>;
}
How much effort does it take to get started?
recoil requires a root provider.
<RecoilRoot>.// recoil: Mandatory Root Provider
import { RecoilRoot } from 'recoil';
function App() {
return (
<RecoilRoot>
<Dashboard />
</RecoilRoot>
);
}
zustand needs no provider.
// zustand: No provider needed
// Can be imported directly in any file
import useStore from './store';
// Even works in non-React files
const initialData = useStore.getState();
react-tracked requires a context provider.
useContext with a tracked version.// react-tracked: Context-based Provider
import { Provider } from './context';
function App() {
return (
<Provider>
<Dashboard />
</Provider>
);
}
You have a dashboard with 20+ widgets showing different stats from a large dataset.
recoil// recoil: Independent atoms for each widget metric
const revenueAtom = atom({ key: 'revenue', default: 0 });
const usersAtom = atom({ key: 'users', default: 0 });
// Each widget subscribes to exactly one atom
You need to add global state to an existing app without refactoring the whole tree.
zustand// zustand: Drop-in usage
const useAuth = create(...);
// Use in any component without wrapping App
const user = useAuth((s) => s.user);
Your state is a large nested object (e.g., a complex form or game state) and you hate writing selectors.
react-tracked// react-tracked: Deep tracking without selectors
function FormField() {
const { state } = useFormContext();
// Tracks state.user.profile.email automatically
return <input value={state.user.profile.email} />;
}
Every tool has its limits. Here is what to watch out for:
recoil has a steeper learning curve. The concept of atoms and selectors can feel heavy for simple apps. Also, it historically had issues with concurrent mode (though improving), and the API surface is larger.zustand puts the burden of optimization on you. If you forget a selector, your app might become slow. It also lacks built-in derived state caching like Recoil selectors.react-tracked depends on Proxies. This means it does not support Internet Explorer 11. Also, because tracking is automatic, it can sometimes be harder to debug exactly why a component re-rendered if you aren't familiar with how Proxies work.| Feature | recoil | zustand | react-tracked |
|---|---|---|---|
| Core Model | Atomic (Atoms/Selectors) | Single Store (Hook) | Proxy-wrapped Context |
| Optimization | Automatic (by atom) | Manual (via selectors) | Automatic (by property) |
| Setup | Requires <RecoilRoot> | No provider needed | Requires <Provider> |
| Derived State | Built-in Selectors | Manual / Store logic | Standard React logic |
| External Use | No (hooks only) | Yes (getState()) | No (context based) |
| Browser Support | Modern + Polyfills | All (including IE11) | Modern (No IE11) |
Choosing the right tool depends on your team's preferences and the complexity of your state.
recoil is the power user's choice. It shines in large applications where state logic is complex and components need fine-grained independence. If you are willing to invest in learning its model, it scales beautifully.
zustand is the pragmatic choice. It is lightweight, flexible, and gets out of your way. It is perfect for most applications, especially if you want simplicity and the ability to use state outside of React components.
react-tracked is the developer experience choice. If you love writing standard React code and hate writing selectors, this library offers the best of both worlds: global state with automatic optimization. Just ensure your browser support requirements allow for Proxies.
Final Thought: All three libraries are mature and capable. The "best" one is the one that fits your team's mental model and keeps your codebase readable.
Choose react-tracked if you want automatic re-render optimization without writing selectors or managing atomic units. It is ideal for teams that prefer a 'write normal React code' approach where the library handles dependency tracking via Proxies. This works best when your state shape is relatively stable and you want to avoid the boilerplate of defining selectors for every component.
Choose recoil if your application has complex, interdependent state logic that benefits from an atomic architecture. It excels in scenarios where different parts of the UI need to access tiny slices of state independently, or when you need derived state (selectors) that recomputes automatically. Be aware that it requires a specific app structure (RecoilRoot) and a shift in mental model towards atoms and selectors.
Choose zustand if you need a lightweight, flexible store that works outside of React components and requires minimal setup. It is perfect for projects that want a simple API with explicit control over subscriptions via selectors. Its lack of required providers and ability to hold non-React state makes it a versatile choice for both small widgets and large-scale applications.
State usage tracking with Proxies. Optimize re-renders for useState/useReducer, React Redux, Zustand and others.
Documentation site: https://react-tracked.js.org
Preventing re-renders is one of performance issues in React. Smaller apps wouldn't usually suffer from such a performance issue, but once apps have a central global state that would be used in many components. The performance issue would become a problem. For example, Redux is usually used for a single global state, and React-Redux provides a selector interface to solve the performance issue. Selectors are useful to structure state accessor, however, using selectors only for performance wouldn't be the best fit. Selectors for performance require understanding object reference equality which is non-trival for beginners and experts would still have difficulties for complex structures.
React Tracked is a library to provide so-called "state usage tracking." It's a technique to track property access of a state object, and only triggers re-renders if the accessed property is changed. Technically, it uses Proxies underneath, and it works not only for the root level of the object but also for deep nested objects.
Prior to v1.6.0, React Tracked is a library to replace React Context use cases for global state. React hook useContext triggers re-renders whenever a small part of state object is changed, and it would cause performance issues pretty easily. React Tracked provides an API that is very similar to useContext-style global state.
Since v1.6.0, it provides another building-block API which is capable to create a "state usage tracking" hooks from any selector interface hooks. It can be used with React-Redux useSelector, and any other libraries that provide useSelector-like hooks.
This package requires some peer dependencies, which you need to install by yourself.
npm add react-tracked react scheduler
There are two main APIs createContainer and createTrackedSelector.
Both take a hook as an input and return a hook (or a container including a hook).
There could be various use cases. Here are some typical ones.
useValue custom hookimport { useState } from 'react';
const useValue = () =>
useState({
count: 0,
text: 'hello',
});
This can be useReducer or any hook that returns a tuple [state, dispatch].
import { createContainer } from 'react-tracked';
const { Provider, useTracked } = createContainer(useValue);
const Counter = () => {
const [state, setState] = useTracked();
const increment = () => {
setState((prev) => ({
...prev,
count: prev.count + 1,
}));
};
return (
<div>
<span>Count: {state.count}</span>
<button type="button" onClick={increment}>
+1
</button>
</div>
);
};
The useTracked hook returns a tuple that useValue returns,
except that the first is the state wrapped by proxies and
the second part is a wrapped function for a reason.
Thanks to proxies, the property access in render is tracked and
this component will re-render only if state.count is changed.
const App = () => (
<Provider>
<Counter />
<TextBox />
</Provider>
);
useTrackedSelector from useSelectorimport { useSelector, useDispatch } from 'react-redux';
import { createTrackedSelector } from 'react-tracked';
const useTrackedSelector = createTrackedSelector(useSelector);
const Counter = () => {
const state = useTrackedSelector();
const dispatch = useDispatch();
return (
<div>
<span>Count: {state.count}</span>
<button type="button" onClick={() => dispatch({ type: 'increment' })}>
+1
</button>
</div>
);
};
import create from 'zustand';
const useStore = create(() => ({ count: 0 }));
useTrackedStore from useStoreimport { createTrackedSelector } from 'react-tracked';
const useTrackedStore = createTrackedSelector(useStore);
const Counter = () => {
const state = useTrackedStore();
const increment = () => {
useStore.setState((prev) => ({ count: prev.count + 1 }));
};
return (
<div>
<span>Count: {state.count}</span>
<button type="button" onClick={increment}>
+1
</button>
</div>
);
};
This library internally uses use-context-selector,
a userland solution for useContextSelector hook.
React 18 changes useReducer behavior which use-context-selector depends on.
This may cause an unexpected behavior for developers.
If you see more console.log logs than expected,
you may want to try putting console.log in useEffect.
If that shows logs as expected, it's an expected behavior.
For more information:
https://github.com/dai-shi/lets-compare-global-state-with-react-hooks
The examples folder contains working examples. You can run one of them with
PORT=8080 pnpm run examples:01_minimal
and open http://localhost:8080 in your web browser.
You can also try them directly: 01 02 03 04 05 06 07 08 09 10 11 12 13
See this for details.