@matthesketh/ink-stable-state
v0.1.0
Published
useStableState for Ink: a useState replacement that skips re-renders when the new value is structurally equal to the old one. Kills polling-induced flicker.
Maintainers
Readme
@matthesketh/ink-stable-state
A useState replacement for Ink apps that skips re-renders when the new value is structurally equal to the old one. Kills the flicker you get when a polling hook calls setState every 10 seconds with identical data.
Install
npm install @matthesketh/ink-stable-stateThe problem
A common Ink pattern: fetch some data on a timer, dump it into state, render a list.
interface HealthRow { app: string; ok: boolean }
function useHealth() {
const [data, setData] = useState<HealthRow[]>([]);
useInterval(() => {
fetch('/health').then(setData); // ← flicker: setState even when nothing changed
}, 10_000);
return data;
}React doesn't know your two arrays are structurally equal — it sees a new reference and re-renders. On a terminal that means a visible repaint every 10 seconds even when nothing changed.
The fix
import { useStableState } from '@matthesketh/ink-stable-state';
function useHealth() {
const [data, setData] = useStableState<HealthRow[]>([]);
useInterval(() => {
fetch('/health').then(setData); // ← same value? skipped. Different? re-render.
}, 10_000);
return data;
}That's it. Drop-in for useState, identical signature, works with functional updaters too.
API
useStableState<T>(initial, isEqual?)
const [value, setValue] = useStableState<T>(initial: T, isEqual?: (a: T, b: T) => boolean);| Arg | Type | Default | Description |
|-----|------|---------|-------------|
| initial | T | — | Initial value. Eager — pass a thunk only if you want lazy init via useState(() => …)'s separate API. |
| isEqual | (a, b) => boolean | defaultEquals | Equality function. Returns true when the two values should be treated as the same. |
Returns [value, setValue] — exactly like useState. setValue accepts either a value or a functional updater.
defaultEquals<T>(a, b)
JSON-stringify comparison with the following semantics:
Object.isshort-circuit for identical references and primitives.null/undefinedhandled correctly (asymmetric → not equal).JSON.stringifyfor everything else.- Cyclic structures:
JSON.stringifythrows →defaultEqualsreturnsfalse→ every update is treated as a change (no memoisation benefit, but correctness is preserved). Pass a customisEqualif you care about memoising cyclic data. Map,Set,RegExp, class instances:JSON.stringifydoesn't faithfully represent these types. Two structurally differentMaps both stringify to"{}"and would be incorrectly treated as equal — meaning the UI would silently miss the update. Always pass a customisEqualfor these types.Date: ISO-string serialised, so twoDates pointing at the same millisecond compare equal. Different millisecond → not equal. Works as expected.
Exported separately so you can compose:
import { defaultEquals } from '@matthesketh/ink-stable-state';
const equalIgnoringTimestamp = (a, b) =>
defaultEquals({ ...a, ts: 0 }, { ...b, ts: 0 });When to use it
- ✅ Polling hooks that re-fetch identical data on a timer.
- ✅ Subscription handlers where the source emits redundant updates.
- ✅ Any place you'd reach for
useMemo+useStatetogether to dodge re-renders.
When NOT to use it (or use with a custom isEqual)
- ❌ State that changes every render anyway (e.g. animation frames). Equality check overhead is wasted.
- ❌ State containing
Map,Set,RegExp, class instances, or functions —JSON.stringifydoesn't faithfully represent these. Without a customisEqual,defaultEqualswill give wrong answers (treating distinct values as equal and dropping updates). - ❌ Cyclic structures — they bypass memoisation entirely (always re-render). Pass a custom
isEqualif you need memoisation of cyclic data. - ❌ Massive payloads (multi-MB) where stringify cost exceeds re-render cost. Use a custom shallow
isEqualinstead.
Mutation warning
Treat values as immutable. If you mutate an array/object in place and pass the same reference to setValue, the Object.is short-circuit will fire, the mutation will be lost from React's perspective, and your UI will not update. Always create a fresh value:
// ❌ silently dropped — same reference, Object.is = true
const items = state;
items.push(newRow);
setItems(items);
// ✅ new reference — actually compared and rendered
setItems([...state, newRow]);License
MIT
