beta — in testing

An architecture linter for React and Vue state

Across every state tool, on every PR. Every existing tool answers “what is happening?” statelinter answers “is your state architecture right — and what should it be?”

$  npm i -D statelinter GitHub npm

The problem

State placement decays. And no one tool tells you.

Modern React apps spread state across Context, Redux/RTK, RTK Query, Zustand, TanStack Query, and local useState. Modern Vue apps run the same rot with ref/reactive, Pinia, Vuex, provide/inject, and Nuxt's data-fetching composables. No single dev owns the whole picture in either ecosystem. Over time, with multiple hands in the code, state placement decays: server data cached in three places, props drilled four levels, “global” state read by one component. It becomes an albatross — every tool inspects one silo, in one framework.

statelinter isn't a profiler. It's a source-of-truth auditor that sits above the per-library devtools: it models every state source in your app as one graph and flags the placement mistakes that accumulate when several devs touch the same code. All detection is static — no build step, no browser, no instrumentation. That's what makes it runnable headless in CI on every PR.

The model

Thirteen state surfaces — one graph.

Each source classified server-cache / global-client / local / derived, every read and write an edge. Detectors are pure graph queries, not per-file lint.

statelinter's state graph on a small mixed React and Vue app Five components (Header.tsx, Profile.tsx, Cart.tsx in React; Badge.vue, Checkout.vue in Vue) connected by read and write edges to six state sources: zustand useUiStore, url-param tab, useState user, tanstack-query todos, pinia cart, and local-storage "cart". Badge.vue reads the pinia cart store, Checkout.vue reads and writes it, and Cart.tsx reads and writes the local-storage "cart" key. Both cart sources are highlighted in warning amber with a multiple-sources-of-truth finding tag, because two global sources own the same cart entity. REACT VUE + CONTESTED CART read write Header.tsx Profile.tsx Badge.vue Checkout.vue Cart.tsx url-param · tab zustand · useUiStore useState · user tanstack-query · todos pinia · cart local-storage · "cart" ▲ multiple-sources-of-truth
statelinter · state graph — scroll to pan, rotate for detail

not an artist's impression — scanning examples/mixed-app in this repo produces exactly this graph, and a test pins it. run it yourself: npx statelinter examples/mixed-app --ui

Receipts

The console, on two real example apps.

Terminal output, collapsible severity columns, and full instructions in one page. Edit code, hit Rescan — the analysis re-runs in-process. Everything below is a real capture, not a mockup.

statelinter console in dark theme scanning a React example app — a server-state-in-client-state finding in the terminal and Medium column
React Stack detection sets the accent — React blue, one real finding.
statelinter console in light parchment theme scanning a Vue example app — an over-globalized-state finding on a Pinia store
Vue The console's light parchment theme, Vue green — an over-globalized Pinia store.
statelinter in-console docs, showing Vue-only rows: ref/reactive, Pinia, Vuex, provide/inject, and Nuxt useAsyncData
Vue Docs adapt to the stack — the Vue run surfaces Vue-only surfaces and rules.
A Medium-severity finding card — server-state-in-client-state, with file, line, rule and the named fix
React A real finding — file, rule, and the named refactor. Medium fails CI.

Why these rules

statelinter invented none of its opinions.

It made the ecosystem's existing canon enforceable — rules that already live in docs and blog posts, that nobody runs.

Framework canon

derived-state-as-state is React's “You Might Not Need an Effect” and Vue's computed-over-watchers docs, as a check instead of a paragraph. server-state-in-client-state is the TanStack/TkDodo server-state-is-not-client-state doctrine. prop-drilling is React's “Passing Data Deeply” and Vue's provide/inject guidance.

Community doctrine

over-globalized-state is state colocation, Kent C. Dodds's argument made structural. over-broad-selector is the Redux Style Guide and Zustand's own selector guidance. multiple-sources-of-truth is the single-source-of-truth principle every store library preaches and none of them check for you.

Dogfood hardening

Every guard and hedge — accumulator setters excluded, prefilled drafts softened, updater-form set(prev => …) excluded — came from a false positive on a production codebase and became a permanent regression test.

The rules aren't new. What's new is where they run: cross-file, cross-library, in CI — not in a doc nobody rereads before they ship.

The catalogue

Twelve rules, across thirteen state surfaces.

Recommendations are stack- and framework-aware: statelinter names the dominant tool your app leans on and phrases the fix in the origin framework's idiom.

Rule Fires when Recommends
multiple-sources-of-truth one entity owned by 2+ global sources (context / store / provide-inject / storage / URL / cookie), or server-cached twice Consolidate on one owner
server-state-in-client-state useState/ref fed by fetch/await in an effect or lifecycle hook; prefilled drafts soften to Low TanStack / RTK Query, or Nuxt's useAsyncData/useFetch
derived-state-as-state useState/ref recomputed from other state by a synchronous effect/watcher useMemo/computed — delete the state + effect
storage-as-state a localStorage/sessionStorage key read+written across 2+ components (non-reactive) own it in one store with persist
cookie-as-state a cookie shared across components via js-cookie / react-cookie / document.cookie one reactive owner; persist from there
url-state-forked a useState copy of a URL search param that goes stale on back/forward read the param directly
prop-drilling a JSX prop or Vue template bind passes through N components that only forward it (cross-file, blind hops named) Context/store, or composition (children / slots)
over-globalized-state a global store or context/provide-inject with exactly one real consumer; dead provided values colocate / delete
over-broad-selector bare useStore() or identity selector on a zustand store, or a component-scope $subscribe on a whole pinia store narrow the selector; watch a field, or a persist plugin
defeated-memoReact React.memo receiving inline object/array/function props — the memo never holds stabilize the props, or drop the memo
unstable-context-valueReact a context provider handed an inline object/array/function value (with 1+ consumer) — every consumer re-renders on every provider render useMemo the value (useCallback a function), or hoist a constant
pointless-memoReact useMemo/useCallback with no deps array, or an inline literal in the deps fix the deps, or compute inline

Honesty is the brand

Silence beats lying.

A finding you can't trust is worse than no finding. statelinter would rather stay quiet than guess — and it's explicit about what it doesn't cover yet.

The trust contract

Never guess. Generic entity names (data, state, error) are suppressed, not matched. Dynamic query/storage keys get no source. Unclassifiable state is unknown and never auto-recommended on.

Silence beats lying. Single-owner storage, accumulator setters, event-driven effects, and updater-form set(prev => …) are all excluded. When a scan hits a Vue component shape it can't resolve, it says so on stderr and suppresses “exactly one reader” findings for that run — an undercounted reader is a missed finding, but a false “only one reader” claim would be worse.

  • vue-router URL state isn't modeled yet. useRoute().query isn't tracked; the React URL adapters (useSearchParams, nuqs) are.
  • React class components aren't modeled. Hooks-era React only.
  • Some Vue Options API escape hatches are unresolved. extends, package-imported or dynamic mixin lists, and unrecognized export shapes are flagged on stderr rather than guessed at.

Quickstart

Point it at a folder. Gate a PR.

Works on .tsx / .jsx / .ts / .js / .vue. Requires Node 20+. Test, story, and config files are skipped automatically.

statelinter — quickstart
# install
$ npm i -D statelinter

# scan — pretty output, exit 1 on Medium/High findings (the CI gate)
$ npx statelinter src/

# serve the interactive findings console (one-click Rescan, binds 127.0.0.1)
$ npx statelinter --ui src/   → http://localhost:8734

# machine-readable, or tune the prop-drilling threshold
$ npx statelinter src/ --json
$ npx statelinter src/ --min-drill 3
exit 0

clean, or only Low (advisory) findings

exit 1

Medium/High findings — fails CI on purpose

exit 2

usage error (bad flag, no files found)

Contribute

Did we miss anything?

False positives are bugs here.

If statelinter flagged something that's actually fine, that's not noise to shrug at — it's a broken trust contract. File it with the snippet that misfired. Every guard in this codebase started as a false positive on a real repo, and every one is now a permanent regression test. Yours will be too.

Want in?

The contributions that land:

  • a framework pattern the parser doesn't catch yet
  • a rule that misfired on your repo — with the receipts
  • a state tool the graph doesn't model yet