Build a custom React hook that manages a single object in state. useObject holds an object and hands back the current value plus three helpers: merge to apply a partial update, set to replace the whole object, and reset to snap back to the value the hook started with. The interesting part isn't the storage — it's making each change produce a new object so React notices, while merge keeps the keys you didn't touch.
function useObject<T extends object>(initialValue?: T): {
value: T;
merge: (patch: Partial<T>) => void;
set: (next: T) => void;
reset: () => void;
};
initialValue defaults to {} when omitted. merge shallow-merges patch into value, keeping every key not present in patch. set replaces the object wholesale. reset restores the original initialValue, even if the hook is later re-rendered with a different argument.
function ProfileForm() {
const { value, merge, set, reset } = useObject({ name: 'Ada', role: 'dev' });
return (
<div>
<span>{value.name} — {value.role}</span>
<button onClick={() => merge({ role: 'lead' })}>promote</button>
<button onClick={() => set({ name: 'Grace', role: 'admiral' })}>swap</button>
<button onClick={reset}>reset</button>
</div>
);
}
// merge({ role: 'lead' }) -> { name: 'Ada', role: 'lead' } (name survives)
// reset() -> { name: 'Ada', role: 'dev' }
// merge keeps untouched keys; a "merge" that replaces would drop `a`.
const { value, merge } = useObject({ a: 1, b: 2 });
merge({ b: 3 });
// value === { a: 1, b: 3 }, NOT { b: 3 }
merge is shallow. It copies the top-level keys of patch over the current object. Nested objects are replaced, not deep-merged — that is out of scope here.merge has to be expressed in a way that stacks correctly rather than colliding on a stale snapshot.reset targets the first initial value. If the component re-renders and passes a new initialValue, reset still restores the value the hook was created with. Snapshot it so it can't drift.You'll wrap one piece of object state in a small bundle of helpers, each of which rebuilds the object as a brand-new value instead of editing the old one in place.
Forms, settings panels, and filter bars all hold a bag of related fields and flip one at a time: a checkbox here, a text field there, a "reset to defaults" button. A custom hook lets you write that machinery once. useObject owns the object with useState and returns the value alongside three verbs: merge to change a few keys while leaving the rest alone, set to swap the whole object, and reset to return to the starting point. Two things make this trickier than it looks: React only re-renders when you hand it a new object, and merge has to keep the keys you didn't mention.
React stores your object in state as a single reference — think of it as a label pointing at a box of key/value pairs. When a setter runs, React compares the new label against the old one. If they point at the same box, React assumes nothing changed and skips the re-render, even if you quietly edited the pairs inside. Object.assign(value, patch) does exactly that: it writes into the existing box and returns the same label. The fix is to always build a new box that spreads the old keys first and then the patch on top — { ...value, ...patch } — and point the label at that. Different label, so React re-renders; and because the old keys are spread in first, the ones you didn't touch come along for free.
The instinct is to write the new keys straight onto the object you already have, then push it back into state:
const { useState } = require('react');
function useObject(initialValue = {}) {
const [value, setValue] = useState(initialValue);
const merge = (patch) => {
Object.assign(value, patch); // mutates the existing object
setValue(value); // hands React the SAME reference
};
const set = (next) => setValue(next);
const reset = () => setValue(initialValue);
return { value, merge, set, reset };
}
This has two bugs hiding in it. First, Object.assign(value, patch) edits the object in place and setValue(value) hands React the very same reference it already holds — React compares old and new, finds them identical, and bails out of the re-render, so the screen never updates. Second, even if you "fixed" it by writing setValue(patch) instead, you'd drop every key not in the patch: merging { b: 3 } into { a: 1, b: 2 } would leave you with { b: 3 }, because patch is the whole new object. A merge that replaces isn't a merge.
const { useState, useRef } = require('react');
function useObject(initialValue = {}) {
const [value, setValue] = useState(initialValue);
// Snapshot the FIRST initial value so reset survives a changed argument.
// useRef keeps this stable across renders; we only read .current.
const initialRef = useRef(initialValue);
// Build a NEW object: spread the old keys first, then the patch on top, so
// keys not in the patch survive. The functional updater (v) => ... receives
// the latest object React is about to apply, so two merges in one batch
// stack instead of both reading the same stale snapshot.
const merge = (patch) => setValue((v) => ({ ...v, ...patch }));
// Replace the whole object — a plain value is fine, it doesn't depend on
// the current value.
const set = (next) => setValue(next);
// reset goes back to the value the hook was created with, read from the ref.
const reset = () => setValue(initialRef.current);
return { value, merge, set, reset };
}
module.exports = { useObject };
The shift is in how each change is built. merge no longer touches the old object — { ...v, ...patch } returns a fresh object, so React always sees a new reference, and spreading v first means untouched keys carry over. Passing a function to setValue rather than a value lets each updater read the latest object React is about to apply, so several merges in one event compose. And reset reads from initialRef.current instead of the live initialValue parameter, so it always restores the first value even if a re-render later passes a different argument.
Start with useObject({ a: 1 }). The first render calls useState({ a: 1 }), so value is { a: 1 }, and useRef({ a: 1 }) stores that as initialRef.current. The hook returns the three verbs. Now a click handler fires merge({ b: 2 }) and then merge({ c: 3 }) in the same event:
setValue((v) => ({ ...v, ...{ b: 2 } })) is queued. React will call this updater with the latest pending object. Pending starts at { a: 1 }, so this produces { a: 1, b: 2 }.setValue((v) => ({ ...v, ...{ c: 3 } })) is queued behind it. React calls it with { a: 1, b: 2 } — the pending value after step 1 — producing { a: 1, b: 2, c: 3 }.useState returns { a: 1, b: 2, c: 3 }, so value is the merged object and the screen updates.Each step returned a brand-new object, so React never bailed out, and the functional updaters chained so the second merge saw the first merge's result rather than the render-time { a: 1 }. Had these been setValue({ ...value, ...patch }) with a value instead of a function, both would have started from the same render-time { a: 1 } and the first merge's b: 2 would have been lost.
Object.assign(value, patch); setValue(value) hands React the object it already holds, so the reference is unchanged and React skips the re-render — the screen freezes. Fix: build a new object with setValue((v) => ({ ...v, ...patch })).setValue(patch) drops every key not in the patch: merging { b: 3 } into { a: 1, b: 2 } leaves { b: 3 }. Fix: spread the old object first — { ...v, ...patch } — so untouched keys survive.value in a batch. setValue({ ...value, ...patch }) (a value, not a function) captures the render-time value, so two merges in one event both start from the same snapshot and one is lost. Fix: use the functional form setValue((v) => ({ ...v, ...patch })).reset tied to the live argument. setValue(initialValue) reads whatever was passed on the current render, so if the component re-renders with a different initialValue, reset jumps to the new one. Fix: snapshot the first value with useRef and reset to initialRef.current.merge/set/reset functions. That's fine here, but if you pass them to a memoized child they'll defeat the memoization. Wrapping each in useCallback (with an empty dependency array, since the functional updater needs no dependencies) keeps the same reference across renders.merge({ user: { name: 'x' } }) replaces the whole user object. A recursive deep-merge variant would walk nested objects, but it has to decide how to treat arrays and null, which is a real design choice rather than a one-liner.useReducer with action types like { type: 'merge', patch } keeps the update logic in one place and makes batched, interdependent changes easier to reason about than three separate setters.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a custom React hook that manages a single object in state. useObject holds an object and hands back the current value plus three helpers: merge to apply a partial update, set to replace the whole object, and reset to snap back to the value the hook started with. The interesting part isn't the storage — it's making each change produce a new object so React notices, while merge keeps the keys you didn't touch.
function useObject<T extends object>(initialValue?: T): {
value: T;
merge: (patch: Partial<T>) => void;
set: (next: T) => void;
reset: () => void;
};
initialValue defaults to {} when omitted. merge shallow-merges patch into value, keeping every key not present in patch. set replaces the object wholesale. reset restores the original initialValue, even if the hook is later re-rendered with a different argument.
function ProfileForm() {
const { value, merge, set, reset } = useObject({ name: 'Ada', role: 'dev' });
return (
<div>
<span>{value.name} — {value.role}</span>
<button onClick={() => merge({ role: 'lead' })}>promote</button>
<button onClick={() => set({ name: 'Grace', role: 'admiral' })}>swap</button>
<button onClick={reset}>reset</button>
</div>
);
}
// merge({ role: 'lead' }) -> { name: 'Ada', role: 'lead' } (name survives)
// reset() -> { name: 'Ada', role: 'dev' }
// merge keeps untouched keys; a "merge" that replaces would drop `a`.
const { value, merge } = useObject({ a: 1, b: 2 });
merge({ b: 3 });
// value === { a: 1, b: 3 }, NOT { b: 3 }
merge is shallow. It copies the top-level keys of patch over the current object. Nested objects are replaced, not deep-merged — that is out of scope here.merge has to be expressed in a way that stacks correctly rather than colliding on a stale snapshot.reset targets the first initial value. If the component re-renders and passes a new initialValue, reset still restores the value the hook was created with. Snapshot it so it can't drift.You'll wrap one piece of object state in a small bundle of helpers, each of which rebuilds the object as a brand-new value instead of editing the old one in place.
Forms, settings panels, and filter bars all hold a bag of related fields and flip one at a time: a checkbox here, a text field there, a "reset to defaults" button. A custom hook lets you write that machinery once. useObject owns the object with useState and returns the value alongside three verbs: merge to change a few keys while leaving the rest alone, set to swap the whole object, and reset to return to the starting point. Two things make this trickier than it looks: React only re-renders when you hand it a new object, and merge has to keep the keys you didn't mention.
React stores your object in state as a single reference — think of it as a label pointing at a box of key/value pairs. When a setter runs, React compares the new label against the old one. If they point at the same box, React assumes nothing changed and skips the re-render, even if you quietly edited the pairs inside. Object.assign(value, patch) does exactly that: it writes into the existing box and returns the same label. The fix is to always build a new box that spreads the old keys first and then the patch on top — { ...value, ...patch } — and point the label at that. Different label, so React re-renders; and because the old keys are spread in first, the ones you didn't touch come along for free.
The instinct is to write the new keys straight onto the object you already have, then push it back into state:
const { useState } = require('react');
function useObject(initialValue = {}) {
const [value, setValue] = useState(initialValue);
const merge = (patch) => {
Object.assign(value, patch); // mutates the existing object
setValue(value); // hands React the SAME reference
};
const set = (next) => setValue(next);
const reset = () => setValue(initialValue);
return { value, merge, set, reset };
}
This has two bugs hiding in it. First, Object.assign(value, patch) edits the object in place and setValue(value) hands React the very same reference it already holds — React compares old and new, finds them identical, and bails out of the re-render, so the screen never updates. Second, even if you "fixed" it by writing setValue(patch) instead, you'd drop every key not in the patch: merging { b: 3 } into { a: 1, b: 2 } would leave you with { b: 3 }, because patch is the whole new object. A merge that replaces isn't a merge.
const { useState, useRef } = require('react');
function useObject(initialValue = {}) {
const [value, setValue] = useState(initialValue);
// Snapshot the FIRST initial value so reset survives a changed argument.
// useRef keeps this stable across renders; we only read .current.
const initialRef = useRef(initialValue);
// Build a NEW object: spread the old keys first, then the patch on top, so
// keys not in the patch survive. The functional updater (v) => ... receives
// the latest object React is about to apply, so two merges in one batch
// stack instead of both reading the same stale snapshot.
const merge = (patch) => setValue((v) => ({ ...v, ...patch }));
// Replace the whole object — a plain value is fine, it doesn't depend on
// the current value.
const set = (next) => setValue(next);
// reset goes back to the value the hook was created with, read from the ref.
const reset = () => setValue(initialRef.current);
return { value, merge, set, reset };
}
module.exports = { useObject };
The shift is in how each change is built. merge no longer touches the old object — { ...v, ...patch } returns a fresh object, so React always sees a new reference, and spreading v first means untouched keys carry over. Passing a function to setValue rather than a value lets each updater read the latest object React is about to apply, so several merges in one event compose. And reset reads from initialRef.current instead of the live initialValue parameter, so it always restores the first value even if a re-render later passes a different argument.
Start with useObject({ a: 1 }). The first render calls useState({ a: 1 }), so value is { a: 1 }, and useRef({ a: 1 }) stores that as initialRef.current. The hook returns the three verbs. Now a click handler fires merge({ b: 2 }) and then merge({ c: 3 }) in the same event:
setValue((v) => ({ ...v, ...{ b: 2 } })) is queued. React will call this updater with the latest pending object. Pending starts at { a: 1 }, so this produces { a: 1, b: 2 }.setValue((v) => ({ ...v, ...{ c: 3 } })) is queued behind it. React calls it with { a: 1, b: 2 } — the pending value after step 1 — producing { a: 1, b: 2, c: 3 }.useState returns { a: 1, b: 2, c: 3 }, so value is the merged object and the screen updates.Each step returned a brand-new object, so React never bailed out, and the functional updaters chained so the second merge saw the first merge's result rather than the render-time { a: 1 }. Had these been setValue({ ...value, ...patch }) with a value instead of a function, both would have started from the same render-time { a: 1 } and the first merge's b: 2 would have been lost.
Object.assign(value, patch); setValue(value) hands React the object it already holds, so the reference is unchanged and React skips the re-render — the screen freezes. Fix: build a new object with setValue((v) => ({ ...v, ...patch })).setValue(patch) drops every key not in the patch: merging { b: 3 } into { a: 1, b: 2 } leaves { b: 3 }. Fix: spread the old object first — { ...v, ...patch } — so untouched keys survive.value in a batch. setValue({ ...value, ...patch }) (a value, not a function) captures the render-time value, so two merges in one event both start from the same snapshot and one is lost. Fix: use the functional form setValue((v) => ({ ...v, ...patch })).reset tied to the live argument. setValue(initialValue) reads whatever was passed on the current render, so if the component re-renders with a different initialValue, reset jumps to the new one. Fix: snapshot the first value with useRef and reset to initialRef.current.merge/set/reset functions. That's fine here, but if you pass them to a memoized child they'll defeat the memoization. Wrapping each in useCallback (with an empty dependency array, since the functional updater needs no dependencies) keeps the same reference across renders.merge({ user: { name: 'x' } }) replaces the whole user object. A recursive deep-merge variant would walk nested objects, but it has to decide how to treat arrays and null, which is a real design choice rather than a one-liner.useReducer with action types like { type: 'merge', patch } keeps the update logic in one place and makes batched, interdependent changes easier to reason about than three separate setters.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.