useObjectState
EditManages plain-object state with type-safe shallow patches and key updates.
import { useCallback, useEffect, useLayoutEffect, useRef, useState,} from "react";
type Callable = (...args: never[]) => unknown;
export type ObjectStateValue<State extends object> = State extends readonly unknown[] | Callable ? never : State;
export type ObjectStateAction<State extends object> = | State | ((this: void, state: Readonly<State>) => State);
export type ObjectStatePatch<State extends object> = | Partial<State> | ((this: void, state: Readonly<State>) => Partial<State>);
export interface UseObjectStateResult<State extends object> { readonly state: State; readonly setState: (action: ObjectStateAction<State>) => void; readonly patch: (patch: ObjectStatePatch<State>) => void; readonly setKey: <Key extends keyof State>( key: Key, value: State[Key], ) => void; readonly updateKey: <Key extends keyof State>( key: Key, updater: (this: void, value: State[Key]) => State[Key], ) => void; readonly reset: () => void;}
const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
function isPlainObject(value: unknown): value is object { if (typeof value !== "object" || value === null) return false; const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null;}
function assertPlainObject( value: unknown, name: string,): asserts value is object { if (!isPlainObject(value)) { throw new TypeError(`${name} must be a plain object`); }}
function cloneObject<State extends object>(value: State): State { const clone = Object.create(Object.getPrototypeOf(value)) as State; for (const key of Reflect.ownKeys(value)) { Object.defineProperty( clone, key, Object.getOwnPropertyDescriptor(value, key) as PropertyDescriptor, ); } return clone;}
function descriptorsEqual( left: PropertyDescriptor | undefined, right: PropertyDescriptor,): boolean { if (left === undefined) return false; if ( left.configurable !== right.configurable || left.enumerable !== right.enumerable ) { return false; }
const leftIsData = Object.prototype.hasOwnProperty.call(left, "value"); const rightIsData = Object.prototype.hasOwnProperty.call(right, "value"); if (leftIsData !== rightIsData) return false; if (leftIsData) { return ( left.writable === right.writable && Object.is(left.value, right.value) ); }
return left.get === right.get && left.set === right.set;}
function mergeOwnDescriptors<State extends object>( current: State, overrides: object,): State { const descriptors = new Map<PropertyKey, PropertyDescriptor>(); for (const key of Reflect.ownKeys(current)) { descriptors.set( key, Object.getOwnPropertyDescriptor(current, key) as PropertyDescriptor, ); } for (const key of Reflect.ownKeys(overrides)) { descriptors.set( key, Object.getOwnPropertyDescriptor(overrides, key) as PropertyDescriptor, ); }
const next = Object.create(Object.getPrototypeOf(current)) as State; for (const [key, descriptor] of descriptors) { Object.defineProperty(next, key, descriptor); } return next;}
function valueDescriptor( current: object, key: PropertyKey, value: unknown,): PropertyDescriptor { const existing = Object.getOwnPropertyDescriptor(current, key); return { configurable: existing?.configurable ?? true, enumerable: existing?.enumerable ?? true, value, writable: existing !== undefined && Object.prototype.hasOwnProperty.call(existing, "writable") ? (existing.writable ?? false) : true, };}
function replaceOwnValue<State extends object>( current: State, key: PropertyKey, value: unknown,): State { const override = Object.create(null) as object; Object.defineProperty(override, key, valueDescriptor(current, key, value)); return mergeOwnDescriptors(current, override);}
export function useObjectState<State extends object>( initialState: ObjectStateValue<State>,): UseObjectStateResult<State>;export function useObjectState<State extends object>( initialState: State,): UseObjectStateResult<State> { assertPlainObject(initialState, "initialState"); const resetSnapshot = cloneObject(initialState); const [state, setInternalState] = useState(() => cloneObject(resetSnapshot), ); const initialRef = useRef<State>(resetSnapshot);
useIsomorphicLayoutEffect(() => { initialRef.current = resetSnapshot; });
const setState = useCallback((action: ObjectStateAction<State>) => { setInternalState((current) => { const next = typeof action === "function" ? (action as (this: void, state: Readonly<State>) => State)( current, ) : action; assertPlainObject(next, "state"); return next === current ? current : cloneObject(next); }); }, []);
const patch = useCallback((action: ObjectStatePatch<State>) => { setInternalState((current) => { const partial = typeof action === "function" ? action(current) : action; assertPlainObject(partial, "patch");
const changed = Reflect.ownKeys(partial).some( (key) => !descriptorsEqual( Object.getOwnPropertyDescriptor(current, key), Object.getOwnPropertyDescriptor( partial, key, ) as PropertyDescriptor, ), ); if (!changed) return current;
return mergeOwnDescriptors(current, partial); }); }, []);
const setKey = useCallback( <Key extends keyof State>(key: Key, value: State[Key]) => { setInternalState((current) => { if ( Object.prototype.hasOwnProperty.call(current, key) && Object.is(current[key], value) ) { return current; } return replaceOwnValue(current, key, value); }); }, [], );
const updateKey = useCallback( <Key extends keyof State>( key: Key, updater: (this: void, value: State[Key]) => State[Key], ) => { setInternalState((current) => { const currentValue = current[key]; const nextValue = updater(currentValue); if (Object.is(currentValue, nextValue)) return current; return replaceOwnValue(current, key, nextValue); }); }, [], );
const reset = useCallback(() => { setInternalState(cloneObject(initialRef.current)); }, []);
return { state, setState, patch, setKey, updateKey, reset };}Download
Section titled “Download”wget -O src/hooks/useObjectState.ts https://raw.githubusercontent.com/jrTilak/lazykit/HEAD/registry/react-hooks/useObjectState.tstemp_dir="$(mktemp -d)" && bunx degit jrTilak/lazykit/registry/react-hooks "$temp_dir" && mv "$temp_dir/useObjectState.ts" src/hooks/useObjectState.ts && rm -r "$temp_dir"Examples
Section titled “Examples”import { useObjectState } from "./useObjectState";
export function ObjectStateExample() { const form = useObjectState({ name: "", subscribed: false });
return ( <form> <label> Name <input value={form.state.name} onChange={(event) => form.setKey("name", event.currentTarget.value)} /> </label> <label> <input type="checkbox" checked={form.state.subscribed} onChange={(event) => form.setKey("subscribed", event.currentTarget.checked) } /> Subscribe </label> <button type="button" onClick={form.reset}>Reset</button> </form> );}initialState(State) — Plain object used on mount and captured again as the latest reset snapshot. Arrays, functions, and class instances are not supported.
Returns
Section titled “Returns”UseObjectStateResult<State> with state, full setState, shallow patch,
generic setKey and updateKey, and reset. Copies preserve the plain
object’s prototype and every own property descriptor, including symbols,
non-enumerable fields, accessors, and an own __proto__ key.