@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>) anddefineGlobalState(module-level singleton). Based onuseSyncExternalStore, with no extra state dependency. - Middleware — a built-in
persistMiddlewarethat persists state tolocalStorage, plusdefineMiddlewarefor 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-elemententry 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-toolsPeer dependencies:
reactandreact-dom(>= 19).@onoxm/utilsis 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:
keysdrives selective subscription — fields that did not change do not trigger a re-render.- The second item narrows with
keys: afteruseX(['name'])the setter accepts onlyPartial<Pick<T, 'name'>>, and the updater'sprevisPick<T, 'name'>. This is the substantive difference from the global version (whoseuseX.setStatecannot seekeysand therefore always accepts the fullT). To write arbitrary fields, take an extra unnarrowed pair:const [, setAll] = useX(), which isstore.setStateitself. - The narrowing is type-level only: at runtime
previs still the fullT, 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 yieldingundefined. - 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 fullT
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.nameand 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. UseuseUser(['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 whendefineGlobalState()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
createRootboundaries (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):
propertiesomitted ⇒ no filtering (everything is persisted).properties: []⇒ computed by direction: withinclude: true(the default) no field is persisted; withinclude: falsenothing is excluded. This differs fromselectProperties, where[]means "no filtering".- Duplicate keys in
propertiesthrow 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
localStorageas soon as the module is imported. - Storage failures are logged via
console.errorin 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
defineMiddlewareas above — code in your own project is not affected by the library'sdropConsolebuild 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) => voidprop (seePortalComponentProps). Calling it unmounts this instance; when the last instance is gone, the whole root is torn down. - Re-rendering with
shallowEqual-differentpropsupdates 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.
getRootParentis typed asElement | DocumentFragmentso 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
PortalComponentPropsFeatures
- 🔥 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
localStoragepersistence with field filtering - 🧩 Extensible middleware chain (
defineMiddleware) - 🧵 Scoped and global stores sharing one implementation
Author
ono
License
MIT
