react-tracked vs recoil vs zustand
Architecting State Management in React: Atomic, Proxy, and Store Patterns
react-trackedrecoilzustandSimilar Packages:

Architecting State Management in React: Atomic, Proxy, and Store Patterns

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.

Npm Package Weekly Downloads Trend

3 Years

Github Stars Ranking

Stat Detail

Package
Downloads
Stars
Size
Issues
Publish
License
react-tracked02,81937.3 kB172 years agoMIT
recoil019,4242.21 MB3204 years agoMIT
zustand058,69795.2 kB8a month agoMIT

Architecting State Management in React: Atomic, Proxy, and Store Patterns

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.

🏗️ Core Architecture: How State is Structured

recoil breaks state into tiny, independent units called "atoms."

  • Every piece of data is its own atom.
  • Components subscribe only to the specific atoms they use.
  • This allows for extremely granular updates without extra configuration.
// 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.

  • You define a store function with state and actions.
  • Components select specific slices of the store to subscribe to.
  • If you don't use a selector, the whole component re-renders on any change.
// 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.

  • You create a context with a standard React object.
  • The library tracks which properties are read inside components automatically.
  • No selectors or atoms are needed; it just works based on usage.
// 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>;
}

🎯 Optimization Strategy: Selectors vs. Proxies vs. Atoms

The biggest difference between these libraries is how they prevent unnecessary re-renders.

recoil handles this at the source.

  • Since every atom is independent, React naturally knows what to update.
  • You don't need to write optimization code; the architecture enforces it.
  • However, splitting state into too many atoms can make code verbose.
// 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.

  • You must manually tell the hook which part of the state to watch.
  • This gives you full control but adds boilerplate for every component.
  • Forgetting a selector causes the component to re-render on every store update.
// 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.

  • It watches which properties you access during rendering.
  • If you read state.count, it subscribes only to count.
  • This reduces boilerplate but relies on Proxy support (no IE11).
// 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
}

🔄 Handling Derived State and Logic

Complex apps often need state that is calculated from other state.

recoil has built-in "selectors" for derived state.

  • Selectors are first-class citizens that cache results.
  • They automatically re-calculate when their dependencies change.
  • Great for complex data transformations shared across components.
// 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.

  • You can add computed values directly to the store state.
  • Or use middleware like zustand/middleware for persistence.
  • Derived state usually requires manual updates or 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.

  • You calculate derived values inside the component or the provider.
  • Since it tracks property access, calculated values based on state will trigger updates correctly.
  • No special API is needed, just standard JavaScript.
// 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>;
}

🧩 Setup and Integration

How much effort does it take to get started?

recoil requires a root provider.

  • You must wrap your app in <RecoilRoot>.
  • This limits where you can use recoil hooks (must be inside the tree).
  • It creates a clear boundary for state management in your app.
// recoil: Mandatory Root Provider
import { RecoilRoot } from 'recoil';

function App() {
  return (
    <RecoilRoot>
      <Dashboard />
    </RecoilRoot>
  );
}

zustand needs no provider.

  • You can import the store hook anywhere in your code.
  • It works outside of React components (e.g., in utility files).
  • This makes it very easy to drop into existing projects.
// 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.

  • Similar to standard React Context, you wrap parts of your tree.
  • It replaces the need for useContext with a tracked version.
  • You can have multiple tracked contexts for different domains.
// react-tracked: Context-based Provider
import { Provider } from './context';

function App() {
  return (
    <Provider>
      <Dashboard />
    </Provider>
  );
}

🌐 Real-World Scenarios

Scenario 1: Large Dashboard with Many Widgets

You have a dashboard with 20+ widgets showing different stats from a large dataset.

  • ✅ Best choice: recoil
  • Why? The atomic model ensures that updating one stat doesn't re-render unrelated widgets without needing complex selector logic everywhere.
// 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

Scenario 2: Rapid Prototyping or Legacy Migration

You need to add global state to an existing app without refactoring the whole tree.

  • ✅ Best choice: zustand
  • Why? No providers needed. You can import the store in any file and start using it immediately.
// zustand: Drop-in usage
const useAuth = create(...);
// Use in any component without wrapping App
const user = useAuth((s) => s.user);

Scenario 3: Deeply Nested State Objects

Your state is a large nested object (e.g., a complex form or game state) and you hate writing selectors.

  • ✅ Best choice: react-tracked
  • Why? It automatically tracks deep property access. You can pass the whole state object down and it still optimizes renders.
// react-tracked: Deep tracking without selectors
function FormField() {
  const { state } = useFormContext();
  // Tracks state.user.profile.email automatically
  return <input value={state.user.profile.email} />;
}

⚠️ Limitations and Trade-offs

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.

📊 Summary Table

Featurerecoilzustandreact-tracked
Core ModelAtomic (Atoms/Selectors)Single Store (Hook)Proxy-wrapped Context
OptimizationAutomatic (by atom)Manual (via selectors)Automatic (by property)
SetupRequires <RecoilRoot>No provider neededRequires <Provider>
Derived StateBuilt-in SelectorsManual / Store logicStandard React logic
External UseNo (hooks only)Yes (getState())No (context based)
Browser SupportModern + PolyfillsAll (including IE11)Modern (No IE11)

💡 Final Recommendation

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.

How to Choose: react-tracked vs recoil vs zustand

  • react-tracked:

    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.

  • recoil:

    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.

  • zustand:

    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.

README for react-tracked

logo

React Tracked

CI npm size discord

State usage tracking with Proxies. Optimize re-renders for useState/useReducer, React Redux, Zustand and others.

Documentation site: https://react-tracked.js.org

Introduction

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.

Install

This package requires some peer dependencies, which you need to install by yourself.

npm add react-tracked react scheduler

Usage

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.

createContainer / useState

Define a useValue custom hook

import { useState } from 'react';

const useValue = () =>
  useState({
    count: 0,
    text: 'hello',
  });

This can be useReducer or any hook that returns a tuple [state, dispatch].

Create a container

import { createContainer } from 'react-tracked';

const { Provider, useTracked } = createContainer(useValue);

useTracked in a component

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.

Wrap your App with Provider

const App = () => (
  <Provider>
    <Counter />
    <TextBox />
  </Provider>
);

createTrackedSelector / react-redux

Create useTrackedSelector from useSelector

import { useSelector, useDispatch } from 'react-redux';
import { createTrackedSelector } from 'react-tracked';

const useTrackedSelector = createTrackedSelector(useSelector);

useTrackedSelector in a component

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>
  );
};

createTrackedSelector / zustand

Create useStore

import create from 'zustand';

const useStore = create(() => ({ count: 0 }));

Create useTrackedStore from useStore

import { createTrackedSelector } from 'react-tracked';

const useTrackedStore = createTrackedSelector(useStore);

useTrackedStore in a component

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>
  );
};

Notes with React 18

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:

API

docs/api

Recipes

docs/recipes

Caveats

docs/caveats

Related projects

docs/comparison

https://github.com/dai-shi/lets-compare-global-state-with-react-hooks

Examples

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

Benchmarks

See this for details.

Blogs