useDynamicList(initialList) manages a list whose items each carry a stable key — a token minted when an item joins the list, kept for as long as it stays there, and never handed to anything else once it leaves. You get back the list, a getKey(index) that answers for the item sitting at that index, and six helpers that keep the keys lined up with the items through every change.
React uses keys to work out which row of the previous render each row of the next one used to be. The standard advice is to key on a stable id from your data, which quietly assumes your data has one. A form's repeating rows, a list of blank items the user just added, anything not yet saved anywhere — no ids. This hook is what you reach for then: it mints the identity the data does not have.
function useDynamicList<T>(initialList?: T[]): {
list: T[];
getKey: (index: number) => unknown; // the key of the ITEM at that index
push: (item: T) => void; // append
insert: (index: number, item: T) => void;
remove: (index: number) => void;
move: (from: number, to: number) => void;
replace: (index: number, item: T) => void;
resetList: (newList: T[]) => void;
};
getKey(i) is what you hand to React, in place of i:
function Ingredients() {
const { list, getKey, insert, remove } = useDynamicList(['flour', 'sugar']);
return (
<ul>
{list.map((item, i) => (
<li key={getKey(i)}>
<input defaultValue={item} />
<button onClick={() => remove(i)}>remove</button>
</li>
))}
<button onClick={() => insert(0, '')}>add a row at the top</button>
</ul>
);
}
The keys themselves are opaque. What matters is which ones stay the same:
const { list, getKey, insert, move, remove } = useDynamicList(['a', 'b']);
const A = getKey(0); // some key for `a`
const B = getKey(1); // a different key, for `b`
insert(0, 'NEW'); // list is now ['NEW', 'a', 'b']
getKey(1); // still A — `a` moved, and its key came along
getKey(0); // a brand-new key, never seen before
move(0, 2); // ['a', 'b', 'NEW'] — a reorder renumbers nobody
remove(0); // `a` leaves, and A retires: no later item may be given it
getKey(index) is the only way in.replace(index, item) swaps the contents of a row that is already there. The row itself is not going anywhere, so its key stays with it — picture what would happen to a half-typed field if it didn't.insert has one valid index more than the list has items: the gap after the last one.You'll write a list that hands out names, because the thing it is holding does not have any.
You are building the ingredients section of a recipe form. It starts with two blank rows, and there is a button to add another at the top. Nothing here has been saved, so nothing here has an id — there is no database row to borrow a name from. There is just an array of strings, and two of them may well be the same string.
Now a user types flour into the first row and clicks add a row at the top. The word flour is now in the new empty row, and the row labelled first is blank. Nobody typed that. Nobody moved it.
A key is not a label you attach for tidiness. It is the answer to a question React has to ask on every single render of a list: which element from last time is this element now? React lines the old children up against the new ones by key, and whatever it pairs up, it treats as one element that survived — it keeps that element's DOM node and moves it if it must, rather than throwing it away and building a new one.
So the key is what you use to tell React that a row is the same row. Which means key={index} tells React something quite specific, and it is almost never what you meant. Index 0 is not a name. It is a seat number. The item sitting in seat 0 changes all the time; the seat never does.
Read the arrows and the bug stops being mysterious. React compared key 0 to key 0 and key 1 to key 1 and reached the only conclusion available to it: nothing moved, two rows changed their props, one new row appeared at the bottom. So it kept row 0's DOM node and repainted the label. The typed text was never in React's hands — it lives in the DOM node, which is exactly what got recycled underneath it.
Anything React keeps per element rides along the same way: an expanded panel, a focused field, a half-finished CSS transition, a scroll position. They all follow the seat, because the seat is what you named.
The list part is not the hard part, and this is what it looks like when you have written useList before:
const { useState, useCallback } = require('react');
function useDynamicList(initialList = []) {
const [list, setList] = useState(initialList);
const push = useCallback((item) => setList((l) => [...l, item]), []);
const insert = useCallback(
(index, item) => setList((l) => [...l.slice(0, index), item, ...l.slice(index)]),
[],
);
const remove = useCallback((index) => setList((l) => l.filter((_, i) => i !== index)), []);
// ...and move, replace and resetList, all of them equally correct
// Every item already has something that tells it apart from its neighbours.
const getKey = useCallback((index) => index, []);
return { list, getKey, push, insert, remove, move, replace, resetList };
}
This is not a strawman. It is a genuinely good list hook, and it passes twelve of this question's nineteen tests. Every verb does the right thing. No array is mutated. The helpers are stable. Run it and the list works.
It is wrong in exactly one place, and it is the place the hook exists for. getKey is being asked which item is at this index and is answering this index. That is not a lookup. It is a restatement of the question, and it can only ever describe where an item is standing right now — which is the one property that changes every time the list does.
The tell is that getKey never reads list. A function that claims to identify items but never looks at them is not identifying anything.
const { useState, useCallback } = require('react');
// Does `arr` hold index `i`? An index it does not hold names nothing.
const holds = (arr, i) => Number.isInteger(i) && i >= 0 && i < arr.length;
// Insert points are the gaps BETWEEN items, so there is one more of them than
// there are items: accepts(['a','b'], 2) is true and means "append".
const accepts = (arr, i) => Number.isInteger(i) && i >= 0 && i <= arr.length;
// Three array moves. Each gets used TWICE per operation — once on the items and
// once on the keys, at the same index — and that pairing is the whole hook.
const spliceIn = (arr, i, value) => [...arr.slice(0, i), value, ...arr.slice(i)];
const spliceOut = (arr, i) => [...arr.slice(0, i), ...arr.slice(i + 1)];
const relocate = (arr, from, to) => spliceIn(spliceOut(arr, from), to, arr[from]);
function useDynamicList(initialList = []) {
// ONE cell holding three fields. items[i] and keys[i] are the same fact seen
// twice, so they are stored as a single value that can only ever change
// together: there is no way to update one and forget the other, because
// there is no "one" to update. nextKey rides along for the same reason.
const [state, setState] = useState(() => ({
items: initialList,
keys: initialList.map((_, i) => i),
nextKey: initialList.length,
}));
// A reader, not a writer, so it answers out of the render it was born in.
// The `list` and the `getKey` handed back together always describe each
// other — which is the same guarantee the state cell above is making.
const getKey = (index) => state.keys[index];
// Every writer below is ONE pure updater: it reads `s`, returns a new object,
// and touches nothing else. That purity is what makes minting a key in here
// safe. React does not promise to run an updater exactly once — StrictMode
// deliberately runs it twice — so a key minted by incrementing a counter that
// lives OUTSIDE this function would be minted twice, and the two arrays would
// silently drift apart. Keeping the counter in `s` makes that impossible.
const push = useCallback((item) => {
setState((s) => ({
items: [...s.items, item],
keys: [...s.keys, s.nextKey],
nextKey: s.nextKey + 1,
}));
}, []);
const insert = useCallback((index, item) => {
setState((s) =>
accepts(s.items, index)
? {
items: spliceIn(s.items, index, item),
keys: spliceIn(s.keys, index, s.nextKey), // same splice, same index
nextKey: s.nextKey + 1,
}
: s, // the same object back: React bails out, and nothing re-renders
);
}, []);
const remove = useCallback((index) => {
setState((s) =>
holds(s.items, index)
? { ...s, items: spliceOut(s.items, index), keys: spliceOut(s.keys, index) }
: s,
);
}, []);
// A reorder creates nothing and destroys nothing, so nextKey does not move.
const move = useCallback((from, to) => {
setState((s) =>
holds(s.items, from) && holds(s.items, to) && from !== to
? { ...s, items: relocate(s.items, from, to), keys: relocate(s.keys, from, to) }
: s,
);
}, []);
// The one verb that leaves `keys` alone: the row stays, its contents change.
const replace = useCallback((index, item) => {
setState((s) =>
holds(s.items, index)
? { ...s, items: s.items.map((el, i) => (i === index ? item : el)) }
: s,
);
}, []);
// A different list means different items, so all of them are new arrivals.
// The counter carries on from where it was rather than rewinding to 0.
const resetList = useCallback((newList) => {
setState((s) => ({
items: newList,
keys: newList.map((_, i) => s.nextKey + i),
nextKey: s.nextKey + newList.length,
}));
}, []);
return { list: state.items, getKey, push, insert, remove, move, replace, resetList };
}
module.exports = { useDynamicList };
Two things changed. A second array appeared that holds one key per item, minted the moment that item arrives — and every verb now performs its operation twice, once on each array, at the same index. And a counter appeared that hands out the keys.
Everything else follows. getKey finally reads something: state.keys[index], which was put there by whichever operation admitted that item and has been carried along by every operation since.
This is the picture worth keeping. Look at the index row and the whole list moved: a was 0 and is now 1. Look at the keys row and almost nothing happened: a is still key 0, b is still 1, c is still 2, and there is one new number in the list because there is one new item in the list.
That is precisely what you want React to be told. a did not change — it moved. A key that moves with it says so; a key that stays behind says the opposite.
The reason spliceIn, spliceOut and relocate are lifted out of the hook is that each is called twice per operation with the same index. A splice at index 0 in one array and a splice at index 0 in the other keep the two lined up by construction, whatever the index is — including the ones that clamp or fall off the end, which is why the guards sit in front of both rather than inside either.
There is a tempting way to avoid keeping a counter at all. You already have the keys; the next one could just be one past the biggest:
const nextKey = Math.max(...state.keys) + 1; // don't
It works right up until somebody removes the item with the biggest key.
max(keys) + 1 is a question about the survivors, and survivors are exactly the wrong population. Remove the newest item and the maximum drops, so the next arrival is handed the key that just died. In one batch — remove(3) then push('e') — React compares the key list before against the key list after, finds [0,1,2,3] both times, concludes that nothing structural happened, and keeps d's row for e to move into. The bug this hook exists to prevent, reintroduced by a one-liner that looks like a cleanup. (It also returns -Infinity on an empty list, which is the smaller problem.)
nextKey counts arrivals. Nothing gives a key back — not remove, not resetList, not emptying the list entirely. It only ever climbs, so no two items in the life of one list can collide.
Mount the recipe form: useDynamicList(['flour', 'sugar']).
{ items: ['flour','sugar'], keys: [0, 1], nextKey: 2 }. getKey(0) is 0, getKey(1) is 1. React renders two rows keyed 0 and 1, and mounts a DOM node for each.input element, not in React — an uncontrolled field's value belongs to the DOM node.insert(0, '') runs. accepts(['flour','sugar'], 0) is true, so the updater returns { items: ['', 'flour', 'sugar'], keys: [2, 0, 1], nextKey: 3 }. The blank string got key 2 — the next number the counter had — and it was spliced into keys at index 0, the same index the item went to. flour and sugar did not move within keys; they got pushed along by the splice, still holding 0 and 1.2, 0, 1. It matches the new key 0 against the old key 0 and finds flour's row, so it moves that DOM node — the one holding the typed text — down to position 1. It does the same for 1. Key 2 matches nothing from last time, so it mounts a fresh row with an empty input at the top.flour, one position further down. Which is what the user did.Now run step 4 against the first attempt. The old keys were [0, 1] and the new keys are [0, 1, 2]. React matches 0 to 0 and keeps row 0's DOM node exactly where it is — and row 0 now renders the blank string. The text reads plain flour, in the row that is supposed to be empty. Nothing threw, nothing warned, and the console is clean.
Of the four big hook collections, only ahooks ships this. react-use has a useList, and @react-hookz/web has a useList, a useQueue and a useSet — none of them mints keys. usehooks-ts has no list hook at all. So there is one prior implementation to compare against, and it agrees with this one about nearly everything that matters: a monotonic counter, one key minted per arrival, a parallel key array spliced alongside the items, keys never recycled, the counter surviving resetList, and replace leaving the key alone. Its surface is much wider — fifteen members, including merge, batchRemove, getIndex, pop, shift and sortList.
Where it differs is where the keys live, and that turns out to matter more than it looks:
// ahooks/src/useDynamicList/index.ts, trimmed
const keyList = useRef([]);
const setKey = useCallback((index) => {
counterRef.current += 1; // mutate
keyList.current.splice(index, 0, counterRef.current); // mutate
}, []);
const insert = useCallback((index, item) => {
setList((l) => {
const temp = [...l];
temp.splice(index, 0, item);
setKey(index); // ...called from INSIDE the updater
return temp;
});
}, []);
The keys are in a ref rather than in the state, so they cannot ride along inside one setList — and setKey gets called from inside the updater instead. React's rules are explicit that an updater must be pure, and StrictMode exists to catch the ones that aren't: it calls functions that you pass to useState, set functions, useMemo, or useReducer twice in development, precisely because a pure function survives that and an impure one does not. setKey increments a counter and splices an array. It does not survive.
Measured on React 19.2.6, against ahooks 3.9.7 from npm — the same hook, the same list, the only difference being a StrictMode wrapper:
mount ['a','b','c'], then… | keys of a, b, c | |
|---|---|---|
insert(0, 'X') — no StrictMode | [0, 1, 2] → [0, 1, 2] | intact |
insert(0, 'X') — StrictMode | [3, 4, 5] → [6, 3, 4] | every survivor renumbered |
move(0, 2) — no StrictMode | [0, 1, 2] → [1, 2, 0] | reordered, nobody renamed |
move(0, 2) — StrictMode | [3, 4, 5] → [5, 3, 4], should be [4, 5, 3] | rotated onto the wrong items |
Read the second row slowly. a went into that insert holding key 3 and came out holding 6. b came out holding 3 — the key that belonged to a. Under StrictMode, ahooks' useDynamicList renumbers every survivor on an insert and hands out keys that other items were using, which is a more energetic version of the exact bug it was built to prevent. move rotates every key onto the wrong item, and batchRemove leaves an item with no key at all. remove desyncs for every index except the last, which happens to survive by arithmetic accident.
And the tell is sitting in the same file. pop and shift trim the key list from the callback body rather than from inside the updater — and both come through StrictMode perfectly, along with push and replace. Two ways of doing one job, ten lines apart, and only one of them is a pure updater.
The honest limit on this. StrictMode's double-invocation is development-only and does not touch a production build, so ahooks' keys are correct in the app you ship. That sounds like a reprieve and mostly isn't: StrictMode is on by default in Next.js's app router (since 13.5.1) and React recommends it everywhere, so this fires exactly where you would be looking at your list. A list whose rows swap their identities in dev and behave in prod is a bad afternoon — the symptom points at your code, the fix is in a dependency, and every attempt to reproduce it in production makes it disappear.
Where this hook differs on purpose. Keeping keys and nextKey in the same useState cell as items is not tidiness — it is what removes the possibility. One cell means one pure updater per operation, so double-invoking it is a no-op, and there is no arrangement of renders in which the keys and the items can disagree, because they are the same value. It also costs a useRef and a useCallback. The bug and the machinery leave together, which is usually the sign you found the right seam.
key={index}. The bug this question is made of. It names the seat rather than the occupant, so React concludes that a row which actually moved merely changed its props — and hands its DOM node, its typed text, its focus and its open/closed state to whatever moved in. Fix: key on something minted when the item arrived.key={item} or key={JSON.stringify(item)}. The next thing people reach for, and it is worse than the index in one way: two rows holding the same blank string get the same key, and React warns about duplicates and then reconciles them wrongly. A value is not an identity — an ingredients list is allowed to contain salt twice.setState updater, is a side effect in a function React is allowed to call more than once — and does, under StrictMode. The keys and the items drift apart, and getKey starts describing the wrong row. Fix: put the counter in the state and let the pure updater read it.Math.max(...keys) + 1. Looks like it saves you a field. It asks the survivors for the next name, so removing the newest item frees its key for the next arrival to inherit, along with the DOM node React still associates with it. Fix: count arrivals, not survivors.replace. replace(index, item) is how a controlled row's edit lands, so a fresh key there unmounts and remounts the row on every keystroke — the field loses focus mid-word. Fix: replace touches items only. If you genuinely mean a different item, remove then insert says so, and mints accordingly.getKey(2) quietly describing the item that used to be at index 2. Fix: don't leave yourself the option — one state cell, one updater, both arrays.getIndex(key). The inverse lookup, and the natural next member: given a key from a drag event or a focused row, which index is that now? ahooks ships it as a findIndex over the key array. It is three lines and it is what a sortable list wants when the drop handler knows the key but needs the position.{ key: 3, value: 'flour' } — so identity travels inside the item and no second array can fall out of step. It removes this whole class of bug, at the price of every caller unwrapping .value, and it is why libraries like react-hook-form's useFieldArray return objects with an id baked in rather than your raw values.crypto.randomUUID() instead of a counter. Unique without a tally, and never reused by construction. It costs more per mint and the keys stop being readable in DevTools, but it is the right move the moment keys have to be unique across more than one list — merging two lists, or sending rows to a server that keys them too.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
useDynamicList(initialList) manages a list whose items each carry a stable key — a token minted when an item joins the list, kept for as long as it stays there, and never handed to anything else once it leaves. You get back the list, a getKey(index) that answers for the item sitting at that index, and six helpers that keep the keys lined up with the items through every change.
React uses keys to work out which row of the previous render each row of the next one used to be. The standard advice is to key on a stable id from your data, which quietly assumes your data has one. A form's repeating rows, a list of blank items the user just added, anything not yet saved anywhere — no ids. This hook is what you reach for then: it mints the identity the data does not have.
function useDynamicList<T>(initialList?: T[]): {
list: T[];
getKey: (index: number) => unknown; // the key of the ITEM at that index
push: (item: T) => void; // append
insert: (index: number, item: T) => void;
remove: (index: number) => void;
move: (from: number, to: number) => void;
replace: (index: number, item: T) => void;
resetList: (newList: T[]) => void;
};
getKey(i) is what you hand to React, in place of i:
function Ingredients() {
const { list, getKey, insert, remove } = useDynamicList(['flour', 'sugar']);
return (
<ul>
{list.map((item, i) => (
<li key={getKey(i)}>
<input defaultValue={item} />
<button onClick={() => remove(i)}>remove</button>
</li>
))}
<button onClick={() => insert(0, '')}>add a row at the top</button>
</ul>
);
}
The keys themselves are opaque. What matters is which ones stay the same:
const { list, getKey, insert, move, remove } = useDynamicList(['a', 'b']);
const A = getKey(0); // some key for `a`
const B = getKey(1); // a different key, for `b`
insert(0, 'NEW'); // list is now ['NEW', 'a', 'b']
getKey(1); // still A — `a` moved, and its key came along
getKey(0); // a brand-new key, never seen before
move(0, 2); // ['a', 'b', 'NEW'] — a reorder renumbers nobody
remove(0); // `a` leaves, and A retires: no later item may be given it
getKey(index) is the only way in.replace(index, item) swaps the contents of a row that is already there. The row itself is not going anywhere, so its key stays with it — picture what would happen to a half-typed field if it didn't.insert has one valid index more than the list has items: the gap after the last one.You'll write a list that hands out names, because the thing it is holding does not have any.
You are building the ingredients section of a recipe form. It starts with two blank rows, and there is a button to add another at the top. Nothing here has been saved, so nothing here has an id — there is no database row to borrow a name from. There is just an array of strings, and two of them may well be the same string.
Now a user types flour into the first row and clicks add a row at the top. The word flour is now in the new empty row, and the row labelled first is blank. Nobody typed that. Nobody moved it.
A key is not a label you attach for tidiness. It is the answer to a question React has to ask on every single render of a list: which element from last time is this element now? React lines the old children up against the new ones by key, and whatever it pairs up, it treats as one element that survived — it keeps that element's DOM node and moves it if it must, rather than throwing it away and building a new one.
So the key is what you use to tell React that a row is the same row. Which means key={index} tells React something quite specific, and it is almost never what you meant. Index 0 is not a name. It is a seat number. The item sitting in seat 0 changes all the time; the seat never does.
Read the arrows and the bug stops being mysterious. React compared key 0 to key 0 and key 1 to key 1 and reached the only conclusion available to it: nothing moved, two rows changed their props, one new row appeared at the bottom. So it kept row 0's DOM node and repainted the label. The typed text was never in React's hands — it lives in the DOM node, which is exactly what got recycled underneath it.
Anything React keeps per element rides along the same way: an expanded panel, a focused field, a half-finished CSS transition, a scroll position. They all follow the seat, because the seat is what you named.
The list part is not the hard part, and this is what it looks like when you have written useList before:
const { useState, useCallback } = require('react');
function useDynamicList(initialList = []) {
const [list, setList] = useState(initialList);
const push = useCallback((item) => setList((l) => [...l, item]), []);
const insert = useCallback(
(index, item) => setList((l) => [...l.slice(0, index), item, ...l.slice(index)]),
[],
);
const remove = useCallback((index) => setList((l) => l.filter((_, i) => i !== index)), []);
// ...and move, replace and resetList, all of them equally correct
// Every item already has something that tells it apart from its neighbours.
const getKey = useCallback((index) => index, []);
return { list, getKey, push, insert, remove, move, replace, resetList };
}
This is not a strawman. It is a genuinely good list hook, and it passes twelve of this question's nineteen tests. Every verb does the right thing. No array is mutated. The helpers are stable. Run it and the list works.
It is wrong in exactly one place, and it is the place the hook exists for. getKey is being asked which item is at this index and is answering this index. That is not a lookup. It is a restatement of the question, and it can only ever describe where an item is standing right now — which is the one property that changes every time the list does.
The tell is that getKey never reads list. A function that claims to identify items but never looks at them is not identifying anything.
const { useState, useCallback } = require('react');
// Does `arr` hold index `i`? An index it does not hold names nothing.
const holds = (arr, i) => Number.isInteger(i) && i >= 0 && i < arr.length;
// Insert points are the gaps BETWEEN items, so there is one more of them than
// there are items: accepts(['a','b'], 2) is true and means "append".
const accepts = (arr, i) => Number.isInteger(i) && i >= 0 && i <= arr.length;
// Three array moves. Each gets used TWICE per operation — once on the items and
// once on the keys, at the same index — and that pairing is the whole hook.
const spliceIn = (arr, i, value) => [...arr.slice(0, i), value, ...arr.slice(i)];
const spliceOut = (arr, i) => [...arr.slice(0, i), ...arr.slice(i + 1)];
const relocate = (arr, from, to) => spliceIn(spliceOut(arr, from), to, arr[from]);
function useDynamicList(initialList = []) {
// ONE cell holding three fields. items[i] and keys[i] are the same fact seen
// twice, so they are stored as a single value that can only ever change
// together: there is no way to update one and forget the other, because
// there is no "one" to update. nextKey rides along for the same reason.
const [state, setState] = useState(() => ({
items: initialList,
keys: initialList.map((_, i) => i),
nextKey: initialList.length,
}));
// A reader, not a writer, so it answers out of the render it was born in.
// The `list` and the `getKey` handed back together always describe each
// other — which is the same guarantee the state cell above is making.
const getKey = (index) => state.keys[index];
// Every writer below is ONE pure updater: it reads `s`, returns a new object,
// and touches nothing else. That purity is what makes minting a key in here
// safe. React does not promise to run an updater exactly once — StrictMode
// deliberately runs it twice — so a key minted by incrementing a counter that
// lives OUTSIDE this function would be minted twice, and the two arrays would
// silently drift apart. Keeping the counter in `s` makes that impossible.
const push = useCallback((item) => {
setState((s) => ({
items: [...s.items, item],
keys: [...s.keys, s.nextKey],
nextKey: s.nextKey + 1,
}));
}, []);
const insert = useCallback((index, item) => {
setState((s) =>
accepts(s.items, index)
? {
items: spliceIn(s.items, index, item),
keys: spliceIn(s.keys, index, s.nextKey), // same splice, same index
nextKey: s.nextKey + 1,
}
: s, // the same object back: React bails out, and nothing re-renders
);
}, []);
const remove = useCallback((index) => {
setState((s) =>
holds(s.items, index)
? { ...s, items: spliceOut(s.items, index), keys: spliceOut(s.keys, index) }
: s,
);
}, []);
// A reorder creates nothing and destroys nothing, so nextKey does not move.
const move = useCallback((from, to) => {
setState((s) =>
holds(s.items, from) && holds(s.items, to) && from !== to
? { ...s, items: relocate(s.items, from, to), keys: relocate(s.keys, from, to) }
: s,
);
}, []);
// The one verb that leaves `keys` alone: the row stays, its contents change.
const replace = useCallback((index, item) => {
setState((s) =>
holds(s.items, index)
? { ...s, items: s.items.map((el, i) => (i === index ? item : el)) }
: s,
);
}, []);
// A different list means different items, so all of them are new arrivals.
// The counter carries on from where it was rather than rewinding to 0.
const resetList = useCallback((newList) => {
setState((s) => ({
items: newList,
keys: newList.map((_, i) => s.nextKey + i),
nextKey: s.nextKey + newList.length,
}));
}, []);
return { list: state.items, getKey, push, insert, remove, move, replace, resetList };
}
module.exports = { useDynamicList };
Two things changed. A second array appeared that holds one key per item, minted the moment that item arrives — and every verb now performs its operation twice, once on each array, at the same index. And a counter appeared that hands out the keys.
Everything else follows. getKey finally reads something: state.keys[index], which was put there by whichever operation admitted that item and has been carried along by every operation since.
This is the picture worth keeping. Look at the index row and the whole list moved: a was 0 and is now 1. Look at the keys row and almost nothing happened: a is still key 0, b is still 1, c is still 2, and there is one new number in the list because there is one new item in the list.
That is precisely what you want React to be told. a did not change — it moved. A key that moves with it says so; a key that stays behind says the opposite.
The reason spliceIn, spliceOut and relocate are lifted out of the hook is that each is called twice per operation with the same index. A splice at index 0 in one array and a splice at index 0 in the other keep the two lined up by construction, whatever the index is — including the ones that clamp or fall off the end, which is why the guards sit in front of both rather than inside either.
There is a tempting way to avoid keeping a counter at all. You already have the keys; the next one could just be one past the biggest:
const nextKey = Math.max(...state.keys) + 1; // don't
It works right up until somebody removes the item with the biggest key.
max(keys) + 1 is a question about the survivors, and survivors are exactly the wrong population. Remove the newest item and the maximum drops, so the next arrival is handed the key that just died. In one batch — remove(3) then push('e') — React compares the key list before against the key list after, finds [0,1,2,3] both times, concludes that nothing structural happened, and keeps d's row for e to move into. The bug this hook exists to prevent, reintroduced by a one-liner that looks like a cleanup. (It also returns -Infinity on an empty list, which is the smaller problem.)
nextKey counts arrivals. Nothing gives a key back — not remove, not resetList, not emptying the list entirely. It only ever climbs, so no two items in the life of one list can collide.
Mount the recipe form: useDynamicList(['flour', 'sugar']).
{ items: ['flour','sugar'], keys: [0, 1], nextKey: 2 }. getKey(0) is 0, getKey(1) is 1. React renders two rows keyed 0 and 1, and mounts a DOM node for each.input element, not in React — an uncontrolled field's value belongs to the DOM node.insert(0, '') runs. accepts(['flour','sugar'], 0) is true, so the updater returns { items: ['', 'flour', 'sugar'], keys: [2, 0, 1], nextKey: 3 }. The blank string got key 2 — the next number the counter had — and it was spliced into keys at index 0, the same index the item went to. flour and sugar did not move within keys; they got pushed along by the splice, still holding 0 and 1.2, 0, 1. It matches the new key 0 against the old key 0 and finds flour's row, so it moves that DOM node — the one holding the typed text — down to position 1. It does the same for 1. Key 2 matches nothing from last time, so it mounts a fresh row with an empty input at the top.flour, one position further down. Which is what the user did.Now run step 4 against the first attempt. The old keys were [0, 1] and the new keys are [0, 1, 2]. React matches 0 to 0 and keeps row 0's DOM node exactly where it is — and row 0 now renders the blank string. The text reads plain flour, in the row that is supposed to be empty. Nothing threw, nothing warned, and the console is clean.
Of the four big hook collections, only ahooks ships this. react-use has a useList, and @react-hookz/web has a useList, a useQueue and a useSet — none of them mints keys. usehooks-ts has no list hook at all. So there is one prior implementation to compare against, and it agrees with this one about nearly everything that matters: a monotonic counter, one key minted per arrival, a parallel key array spliced alongside the items, keys never recycled, the counter surviving resetList, and replace leaving the key alone. Its surface is much wider — fifteen members, including merge, batchRemove, getIndex, pop, shift and sortList.
Where it differs is where the keys live, and that turns out to matter more than it looks:
// ahooks/src/useDynamicList/index.ts, trimmed
const keyList = useRef([]);
const setKey = useCallback((index) => {
counterRef.current += 1; // mutate
keyList.current.splice(index, 0, counterRef.current); // mutate
}, []);
const insert = useCallback((index, item) => {
setList((l) => {
const temp = [...l];
temp.splice(index, 0, item);
setKey(index); // ...called from INSIDE the updater
return temp;
});
}, []);
The keys are in a ref rather than in the state, so they cannot ride along inside one setList — and setKey gets called from inside the updater instead. React's rules are explicit that an updater must be pure, and StrictMode exists to catch the ones that aren't: it calls functions that you pass to useState, set functions, useMemo, or useReducer twice in development, precisely because a pure function survives that and an impure one does not. setKey increments a counter and splices an array. It does not survive.
Measured on React 19.2.6, against ahooks 3.9.7 from npm — the same hook, the same list, the only difference being a StrictMode wrapper:
mount ['a','b','c'], then… | keys of a, b, c | |
|---|---|---|
insert(0, 'X') — no StrictMode | [0, 1, 2] → [0, 1, 2] | intact |
insert(0, 'X') — StrictMode | [3, 4, 5] → [6, 3, 4] | every survivor renumbered |
move(0, 2) — no StrictMode | [0, 1, 2] → [1, 2, 0] | reordered, nobody renamed |
move(0, 2) — StrictMode | [3, 4, 5] → [5, 3, 4], should be [4, 5, 3] | rotated onto the wrong items |
Read the second row slowly. a went into that insert holding key 3 and came out holding 6. b came out holding 3 — the key that belonged to a. Under StrictMode, ahooks' useDynamicList renumbers every survivor on an insert and hands out keys that other items were using, which is a more energetic version of the exact bug it was built to prevent. move rotates every key onto the wrong item, and batchRemove leaves an item with no key at all. remove desyncs for every index except the last, which happens to survive by arithmetic accident.
And the tell is sitting in the same file. pop and shift trim the key list from the callback body rather than from inside the updater — and both come through StrictMode perfectly, along with push and replace. Two ways of doing one job, ten lines apart, and only one of them is a pure updater.
The honest limit on this. StrictMode's double-invocation is development-only and does not touch a production build, so ahooks' keys are correct in the app you ship. That sounds like a reprieve and mostly isn't: StrictMode is on by default in Next.js's app router (since 13.5.1) and React recommends it everywhere, so this fires exactly where you would be looking at your list. A list whose rows swap their identities in dev and behave in prod is a bad afternoon — the symptom points at your code, the fix is in a dependency, and every attempt to reproduce it in production makes it disappear.
Where this hook differs on purpose. Keeping keys and nextKey in the same useState cell as items is not tidiness — it is what removes the possibility. One cell means one pure updater per operation, so double-invoking it is a no-op, and there is no arrangement of renders in which the keys and the items can disagree, because they are the same value. It also costs a useRef and a useCallback. The bug and the machinery leave together, which is usually the sign you found the right seam.
key={index}. The bug this question is made of. It names the seat rather than the occupant, so React concludes that a row which actually moved merely changed its props — and hands its DOM node, its typed text, its focus and its open/closed state to whatever moved in. Fix: key on something minted when the item arrived.key={item} or key={JSON.stringify(item)}. The next thing people reach for, and it is worse than the index in one way: two rows holding the same blank string get the same key, and React warns about duplicates and then reconciles them wrongly. A value is not an identity — an ingredients list is allowed to contain salt twice.setState updater, is a side effect in a function React is allowed to call more than once — and does, under StrictMode. The keys and the items drift apart, and getKey starts describing the wrong row. Fix: put the counter in the state and let the pure updater read it.Math.max(...keys) + 1. Looks like it saves you a field. It asks the survivors for the next name, so removing the newest item frees its key for the next arrival to inherit, along with the DOM node React still associates with it. Fix: count arrivals, not survivors.replace. replace(index, item) is how a controlled row's edit lands, so a fresh key there unmounts and remounts the row on every keystroke — the field loses focus mid-word. Fix: replace touches items only. If you genuinely mean a different item, remove then insert says so, and mints accordingly.getKey(2) quietly describing the item that used to be at index 2. Fix: don't leave yourself the option — one state cell, one updater, both arrays.getIndex(key). The inverse lookup, and the natural next member: given a key from a drag event or a focused row, which index is that now? ahooks ships it as a findIndex over the key array. It is three lines and it is what a sortable list wants when the drop handler knows the key but needs the position.{ key: 3, value: 'flour' } — so identity travels inside the item and no second array can fall out of step. It removes this whole class of bug, at the price of every caller unwrapping .value, and it is why libraries like react-hook-form's useFieldArray return objects with an id baked in rather than your raw values.crypto.randomUUID() instead of a counter. Unique without a tally, and never reused by construction. It costs more per mint and the keys stop being readable in DevTools, but it is the right move the moment keys have to be unique across more than one list — merging two lists, or sending rows to a server that keys them too.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.