Build a custom React hook that manages a unique collection of values. A Set is the natural fit when you need membership without duplicates — selected row ids, expanded panel keys, a set of active filters. But a Set is mutable: set.add(x) and set.delete(x) change the existing object in place, and React decides whether to re-render by checking whether you handed it a new value, not by inspecting the contents. useSet(initialValues) owns the set and hands back the current set plus four helpers that change it the right way: add, remove, has, and clear.
function useSet<T>(initialValues?: Iterable<T>): {
set: Set<T>;
add: (item: T) => void;
remove: (item: T) => void;
has: (item: T) => boolean;
clear: () => void;
};
initialValues is any iterable (usually an array) and defaults to empty. add inserts a member; remove drops one; has returns whether a value is currently a member; clear empties the set back to an empty Set.
function TagPicker() {
const { set, add, remove, has } = useSet(['react']);
const toggle = (tag) => (has(tag) ? remove(tag) : add(tag));
return ['react', 'vue', 'svelte'].map((tag) => (
<button key={tag} aria-pressed={has(tag)} onClick={() => toggle(tag)}>
{tag}
</button>
));
}
// 'react' starts pressed; clicking 'vue' adds it; clicking 'react' again removes it
// Each mutating helper returns a brand-new Set; the old one is never changed.
const { set, add, remove, has } = useSet([1, 2]);
add(3); // set is now {1, 2, 3}
add(3); // still {1, 2, 3} — Sets ignore duplicates
remove(1); // {2, 3}
has(2); // true
set.add(item) changes the existing Set and keeps the same reference. React compares the new value to the old by reference, sees no change, and skips the re-render — so the screen goes stale.new Set(prev), then add or delete on the copy, and hand React that fresh object.Set is for. Removing a value that is not present is also a no-op, not an error.You'll wrap one Set in state and a few helpers, each of which rebuilds the collection as a brand-new Set instead of editing the old one in place.
A Set is the right tool whenever you need a collection of unique values — the ids of selected rows, the keys of open panels, the tags a user has picked. Adding a duplicate is a no-op and membership checks are instant. The catch is that a Set is mutable: set.add(x) and set.delete(x) change the existing object and return it. React doesn't look inside the Set to decide whether to re-render; it only checks whether you handed it a different Set than before. So the entire job of useSet is to make every change yield a new Set.
React stores your set in state as a single reference — think of it as a label pointing at a box of values. 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 added a value inside. set.add(item) does exactly that: it drops a value into the existing box and returns the same label. The fix is to always build a new box — new Set(prev) copies the old members into a fresh Set — then add or delete on the copy and point the label at it. Different label, so React re-renders.
The instinct is to reach for the Set methods you already know — add to insert, delete to remove — and then push the set back into state:
const { useState } = require('react');
function useSet(initialValues = []) {
const [set, setSet] = useState(() => new Set(initialValues));
const add = (item) => {
set.add(item); // mutates the existing Set
setSet(set); // hands React the SAME reference
};
const remove = (item) => {
set.delete(item);
setSet(set);
};
const has = (item) => set.has(item);
const clear = () => setSet(new Set());
return { set, add, remove, has, clear };
}
For a single call this often looks like it works in development, which makes the bug so sneaky. But set.add(item) mutates the existing Set and setSet(set) then passes React the very same reference it already holds. React compares old and new, finds them identical, and bails out of the re-render — so set on screen never updates. The list of members silently freezes. Worse, two adds in one event both read and mutate the same captured set, so the result depends on a stale snapshot.
const { useState } = require('react');
function useSet(initialValues = []) {
// Lazy initializer: build the Set once on the first render, not on every one.
const [set, setSet] = useState(() => new Set(initialValues));
// Copy the latest Set, add on the copy, return it. The functional updater
// (prev) => ... receives the Set React is about to apply, so two adds in one
// batch stack instead of both reading the same stale snapshot.
const add = (item) => setSet((prev) => new Set(prev).add(item));
// new Set(prev) is a fresh copy; delete on the copy and return it. Deleting a
// value that isn't present is a harmless no-op, so this never throws.
const remove = (item) =>
setSet((prev) => {
const next = new Set(prev);
next.delete(item);
return next;
});
// Read-only: just ask the current Set. No state change, so no copy needed.
const has = (item) => set.has(item);
// Replace with a brand-new empty Set — a new reference, so React re-renders.
const clear = () => setSet(new Set());
return { set, add, remove, has, clear };
}
module.exports = { useSet };
The shift is purely in how each change is built. Every mutating helper now produces a fresh Set — new Set(prev) copies the members, and .add / .delete run on that copy — so React always sees a new reference and re-renders. And by passing a function to setSet rather than a value, each updater reads the latest Set React is about to apply, so several helper calls in one event compose correctly instead of colliding on a stale snapshot. (new Set(prev).add(item) works as a one-liner because Set.prototype.add returns the set it was called on.)
Start with useSet([1]). The first render runs the lazy initializer () => new Set([1]), so set is {1}, and the hook returns the four helpers. Now a click handler fires add(2) and then add(3) in the same event:
setSet((prev) => new Set(prev).add(2)) is queued. React will call this updater with the latest pending set. Pending starts at {1}, so this copies it and produces {1, 2}.setSet((prev) => new Set(prev).add(3)) is queued behind it. React calls it with {1, 2} — the pending value after step 1 — copies that and adds 3, producing {1, 2, 3}.useState returns {1, 2, 3}, so set is {1, 2, 3} and the screen updates.Each step returned a brand-new Set, so React never bailed out, and the functional updaters chained so the second add saw the first add's result rather than the render-time {1}.
set.add(item); setSet(set) hands React the Set it already holds, so the reference is unchanged and React skips the re-render — the collection freezes on screen. Fix: build a new set with setSet((prev) => new Set(prev).add(item)).delete mutates too. set.delete(item) removes from the existing Set in place and returns a boolean, not the new set. Fix: copy first with new Set(prev), call delete on the copy, and return the copy.set in a batch. setSet(new Set(set).add(item)) (a value, not a function) captures the render-time set, so two adds in one event both start from the same snapshot and one is lost. Fix: use the functional form setSet((prev) => new Set(prev).add(item)).useState(new Set(initialValues)) without the arrow. That builds a fresh Set on every render and throws it away — wasteful, and a subtle source of bugs if initialValues is recomputed. Fix: pass a lazy initializer, useState(() => new Set(initialValues)), so the set is built once.toggle(item) helper. Add the value if it's absent, remove it if present — has(item) ? remove(item) : add(item). It's the single most common Set interaction in UI (multi-select, chip filters), and follows the same copy-then-change rule.useCallback with an empty dependency array keeps the same reference across renders, since the functional updaters need no dependencies.toggle, addMany, intersect), a useReducer with action types like { type: 'add', item } keeps the update logic in one place and makes batched, interdependent changes easier to reason about than 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 unique collection of values. A Set is the natural fit when you need membership without duplicates — selected row ids, expanded panel keys, a set of active filters. But a Set is mutable: set.add(x) and set.delete(x) change the existing object in place, and React decides whether to re-render by checking whether you handed it a new value, not by inspecting the contents. useSet(initialValues) owns the set and hands back the current set plus four helpers that change it the right way: add, remove, has, and clear.
function useSet<T>(initialValues?: Iterable<T>): {
set: Set<T>;
add: (item: T) => void;
remove: (item: T) => void;
has: (item: T) => boolean;
clear: () => void;
};
initialValues is any iterable (usually an array) and defaults to empty. add inserts a member; remove drops one; has returns whether a value is currently a member; clear empties the set back to an empty Set.
function TagPicker() {
const { set, add, remove, has } = useSet(['react']);
const toggle = (tag) => (has(tag) ? remove(tag) : add(tag));
return ['react', 'vue', 'svelte'].map((tag) => (
<button key={tag} aria-pressed={has(tag)} onClick={() => toggle(tag)}>
{tag}
</button>
));
}
// 'react' starts pressed; clicking 'vue' adds it; clicking 'react' again removes it
// Each mutating helper returns a brand-new Set; the old one is never changed.
const { set, add, remove, has } = useSet([1, 2]);
add(3); // set is now {1, 2, 3}
add(3); // still {1, 2, 3} — Sets ignore duplicates
remove(1); // {2, 3}
has(2); // true
set.add(item) changes the existing Set and keeps the same reference. React compares the new value to the old by reference, sees no change, and skips the re-render — so the screen goes stale.new Set(prev), then add or delete on the copy, and hand React that fresh object.Set is for. Removing a value that is not present is also a no-op, not an error.You'll wrap one Set in state and a few helpers, each of which rebuilds the collection as a brand-new Set instead of editing the old one in place.
A Set is the right tool whenever you need a collection of unique values — the ids of selected rows, the keys of open panels, the tags a user has picked. Adding a duplicate is a no-op and membership checks are instant. The catch is that a Set is mutable: set.add(x) and set.delete(x) change the existing object and return it. React doesn't look inside the Set to decide whether to re-render; it only checks whether you handed it a different Set than before. So the entire job of useSet is to make every change yield a new Set.
React stores your set in state as a single reference — think of it as a label pointing at a box of values. 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 added a value inside. set.add(item) does exactly that: it drops a value into the existing box and returns the same label. The fix is to always build a new box — new Set(prev) copies the old members into a fresh Set — then add or delete on the copy and point the label at it. Different label, so React re-renders.
The instinct is to reach for the Set methods you already know — add to insert, delete to remove — and then push the set back into state:
const { useState } = require('react');
function useSet(initialValues = []) {
const [set, setSet] = useState(() => new Set(initialValues));
const add = (item) => {
set.add(item); // mutates the existing Set
setSet(set); // hands React the SAME reference
};
const remove = (item) => {
set.delete(item);
setSet(set);
};
const has = (item) => set.has(item);
const clear = () => setSet(new Set());
return { set, add, remove, has, clear };
}
For a single call this often looks like it works in development, which makes the bug so sneaky. But set.add(item) mutates the existing Set and setSet(set) then passes React the very same reference it already holds. React compares old and new, finds them identical, and bails out of the re-render — so set on screen never updates. The list of members silently freezes. Worse, two adds in one event both read and mutate the same captured set, so the result depends on a stale snapshot.
const { useState } = require('react');
function useSet(initialValues = []) {
// Lazy initializer: build the Set once on the first render, not on every one.
const [set, setSet] = useState(() => new Set(initialValues));
// Copy the latest Set, add on the copy, return it. The functional updater
// (prev) => ... receives the Set React is about to apply, so two adds in one
// batch stack instead of both reading the same stale snapshot.
const add = (item) => setSet((prev) => new Set(prev).add(item));
// new Set(prev) is a fresh copy; delete on the copy and return it. Deleting a
// value that isn't present is a harmless no-op, so this never throws.
const remove = (item) =>
setSet((prev) => {
const next = new Set(prev);
next.delete(item);
return next;
});
// Read-only: just ask the current Set. No state change, so no copy needed.
const has = (item) => set.has(item);
// Replace with a brand-new empty Set — a new reference, so React re-renders.
const clear = () => setSet(new Set());
return { set, add, remove, has, clear };
}
module.exports = { useSet };
The shift is purely in how each change is built. Every mutating helper now produces a fresh Set — new Set(prev) copies the members, and .add / .delete run on that copy — so React always sees a new reference and re-renders. And by passing a function to setSet rather than a value, each updater reads the latest Set React is about to apply, so several helper calls in one event compose correctly instead of colliding on a stale snapshot. (new Set(prev).add(item) works as a one-liner because Set.prototype.add returns the set it was called on.)
Start with useSet([1]). The first render runs the lazy initializer () => new Set([1]), so set is {1}, and the hook returns the four helpers. Now a click handler fires add(2) and then add(3) in the same event:
setSet((prev) => new Set(prev).add(2)) is queued. React will call this updater with the latest pending set. Pending starts at {1}, so this copies it and produces {1, 2}.setSet((prev) => new Set(prev).add(3)) is queued behind it. React calls it with {1, 2} — the pending value after step 1 — copies that and adds 3, producing {1, 2, 3}.useState returns {1, 2, 3}, so set is {1, 2, 3} and the screen updates.Each step returned a brand-new Set, so React never bailed out, and the functional updaters chained so the second add saw the first add's result rather than the render-time {1}.
set.add(item); setSet(set) hands React the Set it already holds, so the reference is unchanged and React skips the re-render — the collection freezes on screen. Fix: build a new set with setSet((prev) => new Set(prev).add(item)).delete mutates too. set.delete(item) removes from the existing Set in place and returns a boolean, not the new set. Fix: copy first with new Set(prev), call delete on the copy, and return the copy.set in a batch. setSet(new Set(set).add(item)) (a value, not a function) captures the render-time set, so two adds in one event both start from the same snapshot and one is lost. Fix: use the functional form setSet((prev) => new Set(prev).add(item)).useState(new Set(initialValues)) without the arrow. That builds a fresh Set on every render and throws it away — wasteful, and a subtle source of bugs if initialValues is recomputed. Fix: pass a lazy initializer, useState(() => new Set(initialValues)), so the set is built once.toggle(item) helper. Add the value if it's absent, remove it if present — has(item) ? remove(item) : add(item). It's the single most common Set interaction in UI (multi-select, chip filters), and follows the same copy-then-change rule.useCallback with an empty dependency array keeps the same reference across renders, since the functional updaters need no dependencies.toggle, addMany, intersect), a useReducer with action types like { type: 'add', item } keeps the update logic in one place and makes batched, interdependent changes easier to reason about than separate setters.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.