@lazylab/react-use-stable-state
v1.0.0
Published
A hook that only updates state if the value has actually changed, with optional deep compare support.
Maintainers
Readme
@lazylab/react-use-stable-state
A React hook that only updates state if the value has actually changed, with optional custom comparison support (like deep or shallow equality).
The Problem
A classic issue in React is triggering unnecessary re-renders when you update state with an object or an array that is structurally identical, but has a new reference:
// This triggers a re-render even though the content is exactly the same!
setState({ page: 1 })Or when you receive data from an API where the content is unchanged but the object reference is new. React compares state by reference (Object.is), not by structure.
The Solution
useStableState provides a minimal, predictable wrapper around useState that allows you to specify exactly how the old and new state should be compared.
🆚 useState vs useStableState
| Feature | useState | useStableState |
|---------|-----------|------------------|
| Comparison Method | Fixed to Object.is (Reference equality) | Pluggable (Default: shallowEqual, supports Deep/Reference) |
| Object Updates | Re-renders if reference changes (even if identical) | Prevents re-render if content is equal |
| API Responses | Triggers re-render on identical polled data | Safely ignores unchanged data |
| Learning Curve | Standard React | Zero (Same API as useState) |
| Bundle Size | Built-in | < 1kb (Zero dependencies) |
Installation
npm install @lazylab/react-use-stable-state
# or
yarn add @lazylab/react-use-stable-state
# or
pnpm add @lazylab/react-use-stable-stateUsage
Basic Usage (Default: shallowEqual)
By default, useStableState uses a built-in shallowEqual comparison. This means if you pass an object with the exact same top-level properties, it will not trigger a re-render:
import { useStableState } from '@lazylab/react-use-stable-state';
function Filters() {
const [filters, setFilters] = useStableState({ page: 1, sort: 'asc' });
const handleUpdate = () => {
// This will NOT trigger a re-render because it is shallowly equal!
setFilters({ page: 1, sort: 'asc' });
};
return <button onClick={handleUpdate}>Update Filters</button>;
}With Reference Equality (Like standard useState)
If you want the exact same behavior as useState (comparing by reference), you can pass Object.is as the compare function:
import { useStableState } from '@lazylab/react-use-stable-state';
function App() {
const [count, setCount] = useStableState(0, { compare: Object.is });
}With Deep Comparison (e.g., fast-deep-equal or lodash.isEqual)
If you have deeply nested objects (like complex API responses), you can inject any comparison library you want:
import { useStableState } from '@lazylab/react-use-stable-state';
import deepEqual from 'fast-deep-equal';
function DataView({ initialData }) {
const [data, setData] = useStableState(initialData, {
compare: deepEqual
});
const handleNewData = (newData) => {
// Only re-renders if the actual deep content changed
setData(newData);
};
}API
useStableState(initialValue, options?)
Parameters
initialValue: The initial state value (or a lazy initializer function() => initialValue).options(optional):compare: A function(prev: T, next: T) => booleanthat returnstrueif the values should be considered equal. Defaults toObject.is.
Returns
Returns a tuple [state, setState] exactly like standard useState. setState supports both direct values and functional updates (prev => next).
shallowEqual(a, b)
A microscopic utility function that performs a shallow comparison of two objects. It returns true if they have the same keys with the same top-level values (compared using Object.is).
Philosophy
We didn't want to:
- Reinvent a state manager
- Create magical proxies
- Force heavy comparison libraries on you
We wanted:
- A predictable, explicit wrapper
- A hook that solves the origin of the problem (
setState) rather than patching the symptoms (e.g.useDeepCompareEffect)
License
MIT
