TanStack Store: The Reactive Engine Hiding in Your node_modules
If you have ever installed TanStack Query, Router, Form, or Table, then @tanstack/store is almost certainly already on your disk. It is the low-level reactive core that every one of those libraries builds its internal reactivity on. That is why its weekly download numbers are enormous — and also why almost nobody imports it directly. It rides along as a transitive dependency, doing its job silently while developers reach for the bigger, friendlier libraries on top.
But @tanstack/store is perfectly usable on its own, and it fills a specific niche: a tiny, zero-dependency, fully type-safe reactive primitive that works the same way regardless of the UI framework around it. It gives you a writable store, derived (computed) values, single-value atoms, async atoms, batching, and fine-grained selector subscriptions — and nothing else. There is no middleware, no devtools, no persistence layer, no opinions about how to structure your app. It is the reactive substrate you would build those things on top of, which is exactly what the TanStack team did.
This article uses the modern 0.11.0 API. If you have read older tutorials showing new Store(...), new Derived(...), and new Effect(...), be aware that the library was redesigned around factory functions and atoms. We will note the differences along the way, but everything below targets the current line.
Why Reach for a Bare Reactive Primitive
Most state libraries are opinionated and framework-coupled. Zustand is React-first and ergonomic out of the box. Redux gives you structure, devtools, and middleware. Valtio leans on proxies so you can just mutate objects. TanStack Store sits at the opposite end of that spectrum on purpose.
It is the right tool when you want one consistent reactive engine that does not care which framework wraps it. The same core drives adapters for React, Solid, Vue, Angular, Svelte, and Lit. So if you are building a framework-agnostic library — or you are already deep in the TanStack ecosystem and want your reactivity to match how Query and Form behave internally — this is the designed-for use case.
The headline capabilities are worth listing plainly:
- Framework-agnostic core with thin adapters for six UI frameworks.
- Zero dependencies in the core and a tiny footprint.
- Type-safe end to end — selectors, derived getters, and store actions are all typed.
- Fine-grained reactivity through selector subscriptions and derived values that recompute only when their dependencies change.
- Immutable updates via
setState, plus batching so multiple writes flush as a single notification. - Built on React's
useSyncExternalStore, so reads are tearing-free under concurrent rendering.
One honest caveat before you commit: it is pre-1.0 and the API still churns between minor versions. Pin your version and read the release notes when you upgrade.
Getting It Installed
You will usually want both the core and the React adapter. The core holds the primitives; the adapter provides the hooks.
npm install @tanstack/store @tanstack/react-store
yarn add @tanstack/store @tanstack/react-store
The core has no dependencies of its own. The React adapter pulls in use-sync-external-store, which it uses under the hood for concurrent-safe reads.
Your First Store and a Fine-Grained Read
A store is a writable container around any value. You create one with createStore, read it through .state (or .get()), and write to it with setState. The updater receives the previous state and returns the next one — updates are immutable.
import { createStore } from '@tanstack/store'
const counterStore = createStore({ count: 0, name: 'Tanner' })
counterStore.state // { count: 0, name: 'Tanner' }
counterStore.get() // same thing
counterStore.setState((s) => ({ ...s, count: s.count + 1 }))
const sub = counterStore.subscribe((value) => console.log(value))
sub.unsubscribe()
In React, you read from a store with useSelector. The selector is the important part: it defines a fine-grained subscription, so a component only re-renders when the slice it selected actually changes.
import { useSelector } from '@tanstack/react-store'
function Count() {
// Re-renders only when `count` changes, never when `name` changes.
const count = useSelector(counterStore, (s) => s.count)
return <p>Count: {count}</p>
}
function Increment() {
return (
<button
onClick={() =>
counterStore.setState((s) => ({ ...s, count: s.count + 1 }))
}
>
+1
</button>
)
}
This selector discipline is the single most important performance habit. If you call useSelector(store) with no selector, you subscribe to the entire store value and any change re-renders the component. Always select the narrowest slice you actually need.
One naming note: in older versions useStore was the primary hook. In 0.11.0 it still exists but is a deprecated alias for useSelector. New code should use useSelector.
Derived Values Without the Bookkeeping
Computed state is where the old and new APIs diverge most sharply. The classic API made you construct a new Derived({ fn, deps: [...] }) with an explicit dependency array and call .mount() to keep it live. The modern API throws all of that away.
To create a derived store, you pass createStore a getter function instead of a value. It auto-tracks whatever it reads, recomputes when those dependencies change, and returns a read-only store with no setState.
import { createStore } from '@tanstack/store'
const counter = createStore({ count: 4 })
// Pass a getter, not a value → a ReadonlyStore that recomputes automatically.
const doubled = createStore(() => counter.state.count * 2)
doubled.state // 8
counter.setState((s) => ({ ...s, count: 10 }))
doubled.state // 20 — no .mount(), no deps array
You consume a derived store in React exactly like a writable one — useSelector works against any source that exposes get() and subscribe().
function Doubled() {
const value = useSelector(doubled)
return <span>{value}</span>
}
When a derived value produces a fresh object or array on every recompute, reference equality will cause spurious re-renders. Pass a comparison function to avoid them. The library ships a shallow helper for exactly this:
import { shallow } from '@tanstack/store'
const evens = useSelector(
numbersStore,
(s) => s.values.filter((n) => n % 2 === 0),
{ compare: shallow },
)
Bundling Actions With a Store
Reaching into a module-level store and calling setState from everywhere works, but it scatters your write logic across components. The third createStore overload lets you attach typed actions directly to the store, keeping reads and writes in one place.
import { createStore } from '@tanstack/store'
const counter = createStore(
{ count: 0 },
({ setState }) => ({
increment: () => setState((s) => ({ ...s, count: s.count + 1 })),
reset: () => setState(() => ({ count: 0 })),
}),
)
counter.actions.increment()
counter.actions.reset()
The actions are fully typed off the store's shape, so your editor autocompletes counter.actions.increment and type-checks the updater. This gives you a lightweight, Zustand-flavored ergonomics without leaving the primitive.
Atoms for Single Values, Including Async
Stores are great for structured objects, but sometimes you just want a single reactive value. That is what atoms are for, and if you have used Jotai, the ergonomics will feel familiar. createAtom with a value gives you a writable atom; createAtom with a getter gives you a derived, read-only atom.
import { createAtom } from '@tanstack/store'
const countAtom = createAtom(0) // writable Atom<number>
countAtom.set(5) // value form
countAtom.set((prev) => prev + 1) // updater form
countAtom.get() // 6
const doubledAtom = createAtom(() => countAtom.get() * 2) // ReadonlyAtom — no .set()
In React, useAtom returns the familiar [value, setter] tuple for a writable atom:
import { createAtom } from '@tanstack/store'
import { useAtom } from '@tanstack/react-store'
const countAtom = createAtom(0)
function Counter() {
const [count, setCount] = useAtom(countAtom)
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>
}
For data fetching, createAsyncAtom wraps a promise into a reactive value carrying a status of 'pending', 'done', or 'error', along with data or error when available.
import { createAsyncAtom } from '@tanstack/store'
const userAtom = createAsyncAtom(() =>
fetch('/api/me').then((r) => r.json()),
)
// userAtom.get() → { status: 'pending' | 'done' | 'error', data?, error? }
This is intentionally minimal — it is not a replacement for TanStack Query's caching, retries, and invalidation. It is a small async primitive for when you need just enough.
Batching, Flushing, and Shared Context
When you perform several writes in a row, you usually do not want subscribers notified after each one. Wrap the writes in batch and they collapse into a single notification with the final value. If you need pending reactive work to run synchronously right now, call flush.
import { batch, flush } from '@tanstack/store'
batch(() => {
countAtom.set(1)
countAtom.set(2)
}) // subscribers fire once, seeing the final value
flush() // force pending updates to run synchronously
For sharing a bundle of typed stores and atoms down a React subtree, createStoreContext gives you a provider and a hook without prop-drilling or reaching for module-level singletons. This is handy when state should be scoped per subtree rather than global.
import type { Atom, Store } from '@tanstack/store'
import { useSelector, createStoreContext } from '@tanstack/react-store'
const { StoreProvider, useStoreContext } = createStoreContext<{
countAtom: Atom<number>
totalsStore: Store<{ count: number }>
}>()
function CountButton() {
const { countAtom, totalsStore } = useStoreContext()
const count = useSelector(countAtom)
const total = useSelector(totalsStore, (s) => s.count)
return <button>{count} of {total}</button>
}
The adapter also offers useCreateStore and useCreateAtom for creating per-component instances that stay stable across renders — useful when you want state scoped to a single mounted component.
Where It Fits, and Where It Doesn't
It is worth being clear-eyed about positioning. If you just need pragmatic React app state with devtools, middleware, and persistence, Zustand or Redux Toolkit will get you there with less ceremony. If you live entirely in React and love the atomic model, Jotai has a richer atom ecosystem. Nano Stores is even tinier for small cross-framework shared state.
TanStack Store earns its place when you want a single, type-safe reactive engine that behaves identically across frameworks — when you are authoring a library, or when you want your own state to match the internals of the TanStack tools you already use. It is small, predictable, immutable by default, and refreshingly unopinionated.
So the next time you peek into node_modules and spot @tanstack/store sitting there, you will know it is not dead weight. It is a capable little reactive primitive that has been quietly powering your favorite libraries all along — and now you know how to put it to work directly.