react-use-reactive
v2.0.0
Published
A custom React hook to mutate state like an ordinary object with deep reactivity
Maintainers
Readme
React useReactive
react-use-reactive is a custom React hook that lets you mutate state like an ordinary JavaScript object while React manages the re-renders. It supports deep reactivity, even for nested objects.
Installation
To install the react-use-reactive hook, run the following command in your project directory:
npm install react-use-reactiveUsage
First, import the useReactive hook:
import useReactive from 'react-use-reactive';Next, use the useReactive hook in your functional components:
import React from 'react';
import useReactive from 'react-use-reactive';
const App = () => {
const state = useReactive({ count: 0, nested: { value: 0 } });
const incrementCount = () => {
state.count++;
};
const incrementNestedValue = () => {
state.nested.value++;
};
return (
<div>
<div>Count: {state.count}</div>
<button onClick={incrementCount}>Increment Count</button>
<div>Nested value: {state.nested.value}</div>
<button onClick={incrementNestedValue}>Increment Nested Value</button>
</div>
);
};
export default App;Usage example with form binding:
...
const state = useReactive({ user: {firstName: '', lastName: ''} });
...
<input
type="text"
value={state.user.firstName}
onChange={(e) => state.user.firstName = e.target.value}
/>
<input
type="text"
value={state.user.lastName}
onChange={(e) => state.user.lastName = e.target.value}
/>This allows for nested objects to also become reactive, instead of a non-reactive object being assigned to a reactive property.
Semantics
- Deep reactivity with synchronous reads. The state is an immutable copy-on-write tree behind a mutable-facade Proxy. A commit advances the tree immediately, so consecutive
state.count++,+=, and repeatedpush()calls in one handler all accumulate and reads are never stale. React still re-renders only once per batched tick. - Root contract. The initial state must be a plain object or an array (including
Object.create(null)). Anything else —undefined, primitives,Date,Map/Set, class instances — throwsTypeError("useReactive initial state must be a plain object or array"). Opaque values are valid as nested leaves, not as the root container. - Reactive and opaque values. Plain objects and arrays are deep-reactive structural values. Everything else — functions,
Date,RegExp, class instances,Map/Set— is opaque: stored by reference, returned as-is, and not proxied. Mutating an opaque object's internals directly (state.date.setFullYear(2030)) is not tracked; replace the value instead (state.date = new Date(...)). - Ownership and copy-in. Structural values in the initial value and in every assignment are deep-copied away from the caller: the store owns its tree and caller objects are never mutated.
state.b = state.acopies instead of aliasing. Each occurrence of a shared reference is copied into its own independent branch. The model is an owned tree, so cycles are rejected (TypeError("useReactive does not support cyclic plain objects or arrays")) and a rejected single-property assignment never partially attaches. - Live read-out references. Assignment into state copies, but reading structural values out returns live reactive references — not snapshots.
{ ...state }.user === state.user, andslice()/map()results can contain live proxies, so mutating a "copy" can mutate state. - Arrays. Array copies preserve exact
length, holes, and enumerable own properties (indices, extra string keys, enumerable symbols). A hole and an explicitundefinedelement differ (in,Object.keys). Array roots (useReactive([1, 2, 3])) are supported. - No-op operations (own-property semantics). Only an existing own property is eligible for same-value comparison (
Object.is). A missing own property assignedundefined— includingpush(undefined)anditems[5] = undefined— always creates it, and shadowing an inherited key (e.g.state.toString = ...) always creates an own property. Deleting a key with no own property is a no-op. Assigning a structural value always creates a new version, even when it looks equal. - Identity (fresh paths). References freshly read from the current tree follow data: the root keeps identity until any data changes, and a nested proxy keeps identity while its own data is unchanged. A previously captured proxy that performs its own write keeps its own identity while its data changes, so key
useMemo/React.memoon freshly read paths (state,state.user). - Root vs nested held references. An old root proxy keeps following the live root. An old nested proxy is path/logical-node bound: it stays live while its node is current and freezes to the captured node once truly replaced. Removed/reordered array elements (
pop,splice,shift,unshift,sort,reverse) are frozen path-bound references; writes through them are inert. - Stale writes. Mutations through stale nested references are ignored and can still report success at the Proxy/
Reflectlevel (Reflect.set(stale, ...) === truewhile state is unchanged). - Unsupported shapes fail loudly. Accessor or non-enumerable structural properties (except the standard array
length),__proto__as a structural key,Object.defineProperty/defineProperties, andObject.preventExtensions/freeze/sealall throw a clearTypeErrorbefore corrupting state; the tree stays usable afterwards. Enumerable data properties are copied as normal mutable state properties; custom descriptor attributes (writable/configurableflags) are not preserved.delete arr.lengththrows the language-level invariantTypeError. A rejected single-property assignment never partially attaches; multi-step natives (Object.assign,splice,fill) apply sequentially, so earlier writes may already have applied before a later value throws — the tree stays internally valid. - Descriptors are safe.
Object.getOwnPropertyDescriptor(state, "user").valuereturns the same reactive proxy asstate.user, never a mutable raw node. - Memoization and React Compiler.
useMemo(..., [state]),useMemo(..., [state.user]), andReact.memowork when keyed on freshly read paths (regression-tested). React Compiler compatibility is not guaranteed: this is an interior-mutability API. - Server Components and SSR. The published entry (
index.js) starts with the'use client'directive, so in RSC environmentsuseReactivemust be called inside a Client Component. The directive does not disable classic SSR: the hook renders duringrenderToStringand hydrates without recoverable errors. Server and client mounts create independent stores, so render the same initial values on both sides. - Mutation during render. Like React state, do not mutate during render; mutate from event handlers and effects.
TypeScript
The package ships with type declarations. index.d.ts is generated from the JSDoc in index.js via npm run build:types — the JSDoc is the source of truth and the two never drift apart.
import useReactive from 'react-use-reactive';
const state = useReactive({ count: 0, user: { name: '' } });
state.count++;
state.user.name = '...';Known typing limitation: the generic stays T extends object so ordinary interfaces (which lack index signatures) remain assignable. TypeScript therefore cannot exclude opaque roots (Date, Map, class instances) at compile time — they are rejected at runtime instead.
Development
npm ci # install from lockfile
npm run lint # oxlint
npm run typecheck # tsc --noEmit (checks the JSDoc in index.js)
npm test # vitest
npm run build:types # regenerate index.d.ts from JSDoc
npm run check:types # fail if index.d.ts is stale
npm run lint:pack # publint
npm run check:pack # verify npm package contents and entry pointContributing
Contributions are welcome! If you find a bug or have a feature request, please open an issue on GitHub. If you'd like to contribute code, please fork the repository and make a pull request.
