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

@onoxm/react-tools

v0.6.7

Published

React tools library by React + Typescript

Readme

@onoxm/react-tools

A React + TypeScript toolkit built around three things:

  • State container — defineScopedState (scoped: one store per <Provider>) and defineGlobalState (module-level singleton). Based on useSyncExternalStore, with no extra state dependency.
  • Middleware — a built-in persistMiddleware that persists state to localStorage, plus defineMiddleware for writing your own.
  • Portal renderer — portalRenderer, which mounts a component into an isolated React root (used by overlays and standalone widgets).

Everything here is also re-exported from the ono-react-element entry point, so consumers of the component library can import these directly from there.

Installation

npm install @onoxm/react-tools
# or
pnpm add @onoxm/react-tools
# or
yarn add @onoxm/react-tools

Peer dependencies: react and react-dom (>= 19). @onoxm/utils is a regular dependency and is installed automatically.

Choosing a state API

The two APIs share one implementation — reading, selective subscription by keys, and middleware behavior are identical. The only difference is where the store lives:

| You want | Use | | --------------------------------------------------------------------------------------------------------------- | ------------------- | | One module-level store, readable/writable outside components, shared across createRoot boundaries (portals) | defineGlobalState | | One definition holding independent state at several places in the tree (multi-tab panels, two wizard forms) | defineScopedState |

Both must be called once at module level. Calling them inside a component creates a new store on every render — for the scoped version it also creates a brand-new Context each time, which makes React unmount and remount the whole subtree (state resets, inputs lose focus, effects re-run) and does not throw any error.

API

defineScopedState(initData, middlewares?)

Defines scoped state. Returns a tuple [Provider, useX].

import { defineScopedState } from '@onoxm/react-tools'

const [UserProvider, useUser] = defineScopedState({ name: '', age: 0 })

function Panel() {
  // Single key → the first item is the VALUE itself
  const [name, setName] = useUser('name')

  // Array form → the first item is a Pick object
  const [data, setData] = useUser(['name', 'age'])

  // No arguments → the full T
  const [all, setAll] = useUser()

  return (
    <>
      <input value={name} onChange={e => setName({ name: e.target.value })} />
      <button onClick={() => setData(prev => ({ age: prev.age + 1 }))}>
        {data.age}
      </button>
    </>
  )
}

// Two independent stores, each with its own state
<UserProvider name="left">
  <Panel />
</UserProvider>
<UserProvider>
  <Panel />
</UserProvider>

Provider — <Provider name?>{children}</Provider>. The store is created lazily on first render and is per instance; unmounting and remounting yields fresh state. name sets the Context displayName (shown in React DevTools).

useX — returns a tuple [data, setState] and has three overloads, one per argument shape:

| Call | Returns | | ----------------------- | -------------------------------------------------------------- | | useX('name') | [T['name'], ScopedSetter<T, 'name'>] — the value itself | | useX(['name', 'age']) | [Pick<T, 'name' \| 'age'>, ScopedSetter<T, 'name' \| 'age'>] | | useX() | [T, Store<T>['setState']] |

Notes:

  • keys drives selective subscription — fields that did not change do not trigger a re-render.
  • The second item narrows with keys: after useX(['name']) the setter accepts only Partial<Pick<T, 'name'>>, and the updater's prev is Pick<T, 'name'>. This is the substantive difference from the global version (whose useX.setState cannot see keys and therefore always accepts the full T). To write arbitrary fields, take an extra unnarrowed pair: const [, setAll] = useX(), which is store.setState itself.
  • The narrowing is type-level only: at runtime prev is still the full T, so { ...prev } spreads everything.
  • Multiple fields must be passed as an array. The spread form useX('name', 'age') is deliberately given no signature (it fails to compile) — it used to type-check while only subscribing to the first key, silently yielding undefined.
  • Must be called inside the matching Provider, otherwise it throws. There is no silent fallback to a default store.

Custom middleware:

const [UserProvider, useUser] = defineScopedState({ name: '' }, [
  loggerMiddleware()
])

defineGlobalState(initData, middlewares?)

Defines global state: a module-level singleton, no Provider required. Returns a single function shaped like zustand — a hook plus three static methods.

import { defineGlobalState } from '@onoxm/react-tools'

export const useUser = defineGlobalState({ name: '', age: 0 })

function Panel() {
  const name = useUser('name') // T['name'] — the value itself
  const { age } = useUser(['age']) // Pick<T, 'age'> — an object
  const all = useUser() // full T

  return (
    <button onClick={() => useUser.setState({ name: 'ono' })}>{name}</button>
  )
}

// Outside components — anywhere, including before mount
useUser.getState().name
useUser.setState({ name: 'ono' })
useUser.setState(prev => ({ age: prev.age + 1 })) // prev is the FULL T
const off = useUser.subscribe(() => console.log(useUser.getState()))

The direct call is the hook and has the same three overloads as the scoped version:

  • useUser('name') ⇒ T['name'] — the value itself (so <p>{name}</p> renders directly)
  • useUser(['name', 'age']) ⇒ Pick<T, 'name' | 'age'>
  • useUser() ⇒ the full T

Notes:

  • Single key yields a value, an array yields an object — this asymmetry is intentional. A single key means "I want this one field"; wrapping it in { name: ... } would force an extra .name and make it un-renderable (Objects are not valid as a React child). The array form must accept dynamic keys (useUser(keys)), so its shape stays an object regardless of contents. Use useUser(['name']) when you want one field but an object shape.
  • The spread form useUser('name', 'age') is rejected for the same reason as above.
  • The static methods (getState / setState / subscribe) are bound when defineGlobalState() is called, so they are available right after module load — before any component mounts. That is precisely why global state exists.
  • It does not depend on Context, so it works anywhere in the tree and across createRoot boundaries (portals, modals, context menus) — and there is no "forgot the Provider" failure mode.

persistMiddleware(options)

Persists state to localStorage and restores it automatically.

import { defineGlobalState, persistMiddleware } from '@onoxm/react-tools'

// Persist everything
const useUser = defineGlobalState({ name: '', token: '' }, [
  persistMiddleware('user')
])

// Persist ONLY the listed fields (new fields are not persisted by default — safer)
const useUser = defineGlobalState({ name: '', age: 0 }, [
  persistMiddleware({ name: 'user', properties: ['name'] })
])

// Persist everything EXCEPT the listed fields (e.g. tokens, large caches)
const useUser = defineGlobalState({ name: '', token: '' }, [
  persistMiddleware({ name: 'user', properties: ['token'], include: false })
])

PersistOptions<T>:

| Option | Type | Default | Meaning | | ------------- | -------------------- | ------- | ------------------------------------------------------------------------- | | name | string | — | The localStorage key | | properties | (keyof T)[] | omitted | Which fields the filter applies to | | include | boolean | true | true = keep only properties; false = exclude them | | rehydrate | boolean | true | Whether to restore from storage on startup | | legacyNames | string \| string[] | omitted | Legacy keys tried in order when name has no value (cross-key migration) |

properties / include share their names and meaning with selectProperties (from @onoxm/utils):

  • properties omitted ⇒ no filtering (everything is persisted).
  • properties: [] ⇒ computed by direction: with include: true (the default) no field is persisted; with include: false nothing is excluded. This differs from selectProperties, where [] means "no filtering".
  • Duplicate keys in properties throw when the store is defined.

The filter applies in three places — first seed, every write, and rehydrate. So a field that is excluded is not read back into state even if it is already sitting in localStorage from an earlier version.

Legacy data: if what we read is a zustand/persist envelope { state, version } (top-level keys confined to state / version), the state is unwrapped before filtering — so data migrated from zustand/persist needs no manual step. If the old data lives under a different key, use legacyNames for cross-key migration: when name has no value they are tried in order, and on a hit the filtered result is written back to name (the old keys are kept, not deleted). Migration only happens during rehydrate; all later writes go to name only.

Only fields that actually carry a value overwrite the initial state: a field is merged only if it exists in localStorage and is not excluded by the filter. Fields missing from storage — even those listed in properties — keep their initial value instead of being overwritten with undefined. Likewise, a key holding only an empty shell ("{}", a name mismatch, null, or an empty string) counts as not found: it neither clobbers the initial value nor blocks the legacyNames fallback.

Notes:

  • Silent degradation when the environment cannot persist (SSR / Node / Worker / private mode / cookies disabled / quota exceeded): nothing is saved or restored, and the store keeps working. Persistence is best-effort.
  • Because the middleware factory body runs at definition time (module evaluation), the first seed may write to localStorage as soon as the module is imported.
  • Storage failures are logged via console.error in development only — they are an expected condition, not a bug.

defineMiddleware(middleware, options?)

Binds an optional rehydrate declaration to a middleware, so the state container can merge persisted data into the initial state.

import { defineMiddleware } from '@onoxm/react-tools'

const loggerMiddleware = () =>
  defineMiddleware(api => next => action => {
    console.log('dispatching', action)
    const result = next(action)
    console.log('next state', api.getState())
    return result
  })

const [Provider, useX] = defineScopedState({ n: 0 }, [loggerMiddleware()])

A middleware is api => next => action => result. Use options.rehydrate when your middleware loads state from somewhere and wants it merged into initData:

defineMiddleware(middleware, {
  rehydrate: () => JSON.parse(localStorage.getItem('x') ?? 'null')
})

The library ships no logging middleware. To observe the action flow, write a few lines with defineMiddleware as above — code in your own project is not affected by the library's dropConsole build setting.

MiddlewareAPI<T> exposes getState() and dispatch(action).

portalRenderer(Component, props, rootId?, componentId?, reverse?, getRootParent?)

Renders a component into an isolated React root and returns a destroy function.

import { portalRenderer } from '@onoxm/react-tools'

interface ToastProps {
  message: string
  destroy?: (onDestroy?: () => void) => void
}

function Toast({ message, destroy }: ToastProps) {
  return <div onClick={() => destroy?.()}>{message}</div>
}

const destroy = portalRenderer(Toast, { message: 'Saved' })

// Later:
destroy()

| Parameter | Type | Default | Meaning | | --------------- | ----------------------------------- | --------------- | ------------------------------------------------------------------------------- | | Component | (props: T) => ReactElement | — | The component to render | | props | Omit<T, 'destroy'> | — | Its props; destroy is injected by the renderer | | rootId | string | generated | Container id. One root per id — use distinct ids for distinct overlay hosts | | componentId | string | generated | Instance id (the React key inside a root) | | reverse | boolean | false | Render the instances in reverse order | | getRootParent | () => Element \| DocumentFragment | document.body | Where the container is mounted |

Notes:

  • The component receives an extra destroy?: (onDestroy?: () => void) => void prop (see PortalComponentProps). Calling it unmounts this instance; when the last instance is gone, the whole root is torn down.
  • Re-rendering with shallowEqual-different props updates the existing instance instead of adding a new one.
  • Returns a no-op function in non-browser environments (SSR-safe).
  • If the cached root has been detached from the document (e.g. its parent modal was closed), the renderer discards the cache and rebuilds it — otherwise new overlays would render into an orphaned node and never appear.
  • getRootParent is typed as Element | DocumentFragment so that Shadow DOM roots (ShadowRoot) can be used as the mount point.

Exports

// Functions
defineScopedState
defineGlobalState
persistMiddleware
defineMiddleware
portalRenderer

// Types
ProviderProps
GlobalStateHook
ScopedStateHook
ScopedSetter
Store
Middleware
MiddlewareAPI
DefineMiddlewareOptions
PersistOptions
PortalComponentProps

Features

  • 🔥 Full TypeScript support — keys are narrowed on both reads and writes
  • 🪶 Based on useSyncExternalStore, no external state dependency
  • 🎯 Selective subscription by keys, so unchanged fields do not re-render
  • 💾 Built-in localStorage persistence with field filtering
  • 🧩 Extensible middleware chain (defineMiddleware)
  • 🧵 Scoped and global stores sharing one implementation

Author

ono

License

MIT