npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

statelinter

v0.2.0

Published

An architecture linter for React and Vue state — across every state tool, on every PR.

Readme

statelinter

An architecture linter for React and Vue state — across every state tool, on every PR.

Every existing tool answers "what is happening?" (this component re-rendered, this store changed). statelinter answers "is your state architecture right — and what should it be?" — across Context, Redux/RTK, RTK Query, Zustand, TanStack Query on the React side; ref/reactive/computed, Pinia, and provide/inject on the Vue 3 side; and the framework-neutral surfaces every app shares — URL params, web storage, cookies — over the whole app, continuously in CI.

It's not 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.

status: beta — in testing npm version CI


Install

npm i -D statelinter

Or skip the install — npx statelinter src/ works with zero setup.

From source (for contributors):

git clone https://github.com/miadugas/statelint.git && cd statelint
npm install
npm run build
npm link          # puts `statelinter` on your PATH

Requires Node 20+.


Usage

Command line

Point it at any folder of .tsx / .jsx / .ts / .js / .vue files:

statelinter src/                 # pretty output, exit 1 on findings (the CI gate)
statelinter src/ --json          # machine-readable findings
statelinter src/ --min-drill 3   # tune the prop-drilling threshold
statelinter src/ --no-color      # plain text (also honors NO_COLOR / pipes)

Example output:

src/App.tsx
  3:7  ▲ warn  prop-drilling
        Prop 'orders' drills through 2 components that only forward it (Shell → Main) before
        reaching OrderList.
        → Pass the element as a child instead of threading 'orders' through Shell → Main, or
        lift to a store if many components need it.
  4:8  ▲ warn  server-state-in-client-state
        'orders' holds server data (fetched in an effect) but lives in useState — a hand-rolled cache
        with no dedup, refetch, or staleness handling.
        → Move to a query library — TanStack Query's useQuery replaces the useState + useEffect +
        fetch triple.

▲ 2 problems (2 warnings)
  1 files scanned in 0.1s

Interactive console (--ui)

For the develop-and-recheck loop, serve the findings console locally:

statelinter --ui src/            # → statelinter console → http://localhost:8734
statelinter --ui --port 3030 src/

Open the URL. You get the terminal output, severity columns (High / Medium / Low, collapsible), and full instructions in one page. Edit your code, click ⟳ Rescan — the page reloads and the server re-runs the full analysis in-process (~1s on a mid-size app). No file juggling, no re-running the CLI. Click any file path to open it in VS Code at the exact line. Ctrl+C stops the server. Nothing leaves your machine — it binds 127.0.0.1 only.

In CI

statelinter exits 1 on any Medium/High finding, so it gates a pipeline as-is:

# .github/workflows/ci.yml
- run: npx statelinter src/

Advisory (Low) findings exit 0 and won't fail the build.


Flags

| Flag | Effect | | --------------- | --------------------------------------------------------------- | | --ui | serve the findings console locally (one-click rescan) | | --port N | console port (default 8734) | | --min-drill N | prop-drilling threshold — blind intermediate hops (default 2) | | --json | machine-readable output | | --no-color | disable colored output (also honors NO_COLOR and pipes) | | -h, --help | show help |

Exit codes

| Code | Meaning | | ---- | ---------------------------------------------- | | 0 | clean, or only Low (advisory) findings | | 1 | Medium/High findings — fails CI on purpose | | 2 | usage error (bad flag, no files found) |

Severity map: High = error · Medium = warn · Low = info.

Test, story, and config files (*.test.*, *.spec.*, *.stories.*, *.config.*, *.setup.*, __tests__/, __mocks__/) are skipped automatically — architecture rules describe the app, not its tests.


The rules

| Rule | Fires when | Recommends | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | multiple-sources-of-truth | one entity owned by 2+ global sources (context / store — zustand, redux, pinia, vuex / 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, grouped per effect; 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 (zustand / redux / pinia / vuex) 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 (its callback runs on every mutation) | narrow the selector (zustand); watch a specific field, or a persist plugin (pinia) | | defeated-memo | React.memo receiving inline object/array/function props — the memo never holds (React only) | stabilize the props, or drop the memo | | unstable-context-value | a context provider handed an inline object/array/function value (<Ctx.Provider> or React 19 <Ctx value>) with 1+ consumer — every consumer re-renders on every provider render (React only) | useMemo the value (useCallback a function), or hoist a constant | | pointless-memo | useMemo/useCallback with no deps array, or an inline literal in the deps (React only) | fix the deps, or compute inline |


What it understands

Thirteen state surfaces across React and Vue, plus memoization, modeled as one graph:

  • Local — React useState/useReducer, with reads/writes/declares tracking; Vue ref/shallowRef/reactive in <script setup> and Options API data() fields
  • Derived — Vue computed — a pure function of other state, so it's never flagged as stored derived state (contrast with the derived-state-as-state anti-pattern below)
  • ContextcreateContext, <Ctx.Provider> + React 19 <Ctx value>, useContext/use, and consumption through custom hooks (useLock() wrapping useContext attributes to callers, transitively)
  • Zustandcreate() + curried create<T>()(), selector vs whole-store reads, setState writes
  • Redux/RTKcreateSlice (identity = slice name), useSelector/useAppSelector state-path resolution
  • RTK QuerycreateApi query endpoints, generated useGetXQuery hooks
  • TanStack Query — v5 object + v4 positional forms, identity = query key (call sites sharing a key share one source, like the real cache)
  • PiniadefineStore (options + setup syntax), storeToRefs, $patch/direct-mutation writes, map helpers (mapStores/mapState/mapGetters/mapActions/mapWritableState); store identity = the defineStore id string
  • VuexcreateStore/new Vuex.Store, root + namespaced modules, this.$store.state/.getters reads, commit/dispatch writes, map helpers, composition useStore()
  • provide/inject — string-keyed provide()/inject() pairs
  • Web storagelocalStorage / sessionStorage, window./globalThis. forms, keyed by storage key
  • Cookies — js-cookie, react-cookie's useCookies, raw document.cookie (reactive vs non-reactive access distinguished)
  • URLuseSearchParams, route params, nuqs; forked-copy detection (React only — see Known limitations)

Plus, not counted as state surfaces but part of the same graph:

  • Memoization (React only) — React.memo, useMemo, useCallback structural breakage, plus unstable context provider values (an inline literal value re-renders every consumer)
  • Nuxt 3 — auto-imports resolved without import statements (with local-shadow guards); Nuxt itself is detected via its own composables (useAsyncData/useFetch/definePageMeta/…), and recommendations switch to those instead of a React query library
  • Vue template analysis:prop binds build the prop-drilling graph across SFCs (kebab-case tags/props normalized, Nuxt auto-registered components resolved by unique name, ambiguity = refuse to guess); {{ interpolation }} and directive expressions count as reads; v-model and handler mutations (@click="x = ...") are detected precisely from the template AST
  • Cross-file — everything resolves through default/named/aliased imports, index.* re-exports, and .vue SFC boundaries; same-named locals in different files never collide

Recommendations are stack-aware and framework-aware: statelinter reads which tools your app leans on and names the dominant one (won't tell a lightly-Redux app to reach for RTK Query), and phrases the fix in the origin framework's idiom — slot composition for Vue prop drilling, ref() colocation for an over-globalized Pinia store, computed() for Vue derived state.


Known limitations

Honesty is the product's brand — here's what it doesn't cover yet:

  • Vue Options API is analyzed, including most escape hatches. data(), computed, props, methods, lifecycle hooks, watch, provide/inject, the setup() option, pinia/Vuex map helpers, and local mixins (in-file or imported object literals) are all modeled. What's still unresolved: extends, mixins that resolve to a package import or a non-literal (dynamic mixin list), and any export shape statelinter doesn't recognize at all. When a scan finds such components, statelinter says so on stderr and suppresses pinia/provide-inject/vuex "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.

Design rules (the trust contract)

  1. Detectors are pure graph queries. All AST work happens once, in the graph builder; detectors read sources/edges only.
  2. 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.
  3. Silence beats lying. Single-owner storage, accumulator setters, event-driven effects, and updater-form set(prev => …) are all excluded. A finding you can't trust is worse than no finding.

Supply chain

  • Two runtime dependencies@typescript-eslint/typescript-estree, @vue/compiler-sfc. 35 packages, ~37 MB transitive on a clean install.
  • Zero install scripts in the dependency tree. Nothing executes on npm install statelinter, and statelinter itself ships no lifecycle scripts. CI installs with npm ci --ignore-scripts — a transitive dep adding one later becomes a build failure, not an execution.
  • Publishing is manual, gated by hardware-key 2FA. No npm tokens exist in CI.
  • Lockfile committed; CI installs from it (npm ci).
  • npm audit: no known vulnerabilities as of 2026-07-11.

Status

Published as statelinter on npm — early/beta, in testing. 263 tests, dogfooded on a production React PWA, a 409-file production Redux app, and a 31-SFC production Nuxt 3 site — where several false positives were found and each became a permanent guard and regression test.

Docs: positioning brief · detector map

Anti-goals

  • ❌ Not a re-render visualizer (React Scan)
  • ❌ Not a single-session in-browser profiler (React DevTools)
  • ❌ Not per-library inspection (Redux/Zustand/Query devtools)
  • ❌ Not file-scoped lint rules (ESLint)

Runtime data (the React Fiber hook) is a planned enrichment layer for ranking findings by real impact — never the architecture.