useMethods(methods, initialState) turns a map of named state transitions into ready-to-call action methods over a useReducer. You pass a factory — a function that takes the current state and returns an object of transitions, each returning the next state — and you get back the state plus one bound method per name. The caller writes increment(), never dispatch({ type: 'increment' }). It is useReducer with the action-type strings and the switch deleted.
function useMethods<S, M>(
methods: (state: S) => { [K in keyof M]: (...args: any[]) => S },
initialState: S,
): [S, { [K in keyof M]: (...args: any[]) => void }];
The bound methods object, and every method on it, keeps the same identity for the life of the component.
A counter. The factory takes the state and returns four transitions; you get four methods:
const counterMethods = (state) => ({
increment: () => state + 1,
decrement: () => state - 1,
add: (n) => state + n,
reset: () => 0,
});
const [count, { increment, add, reset }] = useMethods(counterMethods, 0);
add(5); // count is 5 on the next render
increment(); // 6
reset(); // 0
In real components the factory is written inline, so the transitions can close over props:
function Cart({ taxRate }) {
const [cart, { addItem, clear }] = useMethods(
(state) => ({
addItem: (item) => ({
items: [...state.items, item],
tax: state.tax + item.price * taxRate,
}),
clear: () => ({ items: [], tax: 0 }),
}),
{ items: [], tax: 0 },
);
// addItem is the same function on every render, so ItemList never
// re-renders just because Cart did.
return <ItemList items={cart.items} onAdd={addItem} onClear={clear} />;
}
React.memo children and grab them inside mount-only effects. This has to hold even though the factory above is a brand-new function on every render.methods(initialState). A factory that returns different keys for different states will not grow new methods later.add(5) and setField('email', value) both have to reach the transition intact.0, '', false and null all have to land.You'll take the switch out of a reducer, let the caller hand you the lookup table it always was, and wire one dispatching method per entry — built on the first render and never rebuilt.
A filter panel has eight things it can do to one piece of state: set the query, toggle a tag, clear the tags, pick a date range, reset everything. Write that with useReducer and you get eight case labels, eight action-type strings you have to spell identically in two places, and a switch that every new feature makes longer. Write it with useState and you get eight handlers plus a growing worry about which of them is reading a stale value. useMethods is the third option: write the eight transitions as an object literal, get eight methods back.
Look at what a reducer's switch actually does. It takes a string, finds the branch with that name, and runs that branch against the state. That is a lookup — find the thing called add, run it — written out as control flow. And JavaScript already has a lookup table: an object. So let the caller hand you an object whose keys are the names and whose values are the transitions, and the switch deletes itself; the reducer's whole body collapses to methods(state)[action.type](...). Nothing has been invented here. The table was always there, spelled one case at a time.
The shape falls out almost immediately. Build a reducer that does the lookup, then walk the method names once and hand back a dispatcher for each one.
const { useMemo, useReducer } = require('react');
function useMethods(methods, initialState) {
const reducer = useMemo(
() => (state, action) => methods(state)[action.type](...action.payload),
[methods],
);
const [state, dispatch] = useReducer(reducer, initialState);
const boundMethods = useMemo(() => {
const names = Object.keys(methods(initialState));
return names.reduce((bound, name) => {
bound[name] = (...payload) => dispatch({ type: name, payload });
return bound;
}, {});
}, [methods, initialState]);
return [state, boundMethods];
}
This is not a strawman. It is react-use's shipped useMethods, near enough line for line — and it transitions state correctly. Counting, adding, resetting, composing: all of it works.
What it gets wrong is the promise in the signature. Look at the second dependency array. [methods, initialState] rebuilds the memo whenever either one changes identity, so now write the factory where it belongs — inline, in the component body, where the transitions can close over props:
const [count, { increment }] = useMethods((s) => ({ increment: () => s + step }), 0);
That arrow is a new function on every render. The memo never hits again, so boundMethods and every function on it are rebuilt every render: the React.memo child you passed increment to re-renders on every parent render, and the mount-only effect that captured it is holding a function the hook no longer hands back. The stable identity evaporates for exactly the callers who needed it. And initialState is the same trap one notch quieter — useMethods(listMethods, []) passes a fresh array literal every render, so even a factory declared outside the component rebuilds.
const { useMemo, useReducer, useRef } = require('react');
function useMethods(methods, initialState) {
// ONE box for the life of the component, repointed at the newest factory on
// every render. This is the only line in the hook that reads `methods` —
// below it, nothing can be tied to the factory's identity, because nothing
// can see the factory.
const methodsRef = useRef(methods);
methodsRef.current = methods;
// The switch, deleted. Look the transition up by name in the object the
// factory returns, then call it with the arguments. `payload` is the whole
// argument list, which is why add(5) and setField('email', v) both survive.
const reducer = (state, action) =>
methodsRef.current(state)[action.type](...action.payload);
const [state, dispatch] = useReducer(reducer, initialState);
// One dispatching function per name, built on the first render and never
// again. The empty dependency array IS the stable-identity promise.
const boundMethods = useMemo(() => {
// Read once, off the initial state. That is a real limitation of the
// shape, not an oversight — see Gotchas.
const names = Object.keys(methodsRef.current(initialState));
return names.reduce((bound, name) => {
// `dispatch` is safe to close over: React guarantees its identity never
// changes. That guarantee is what lets this function be built once.
bound[name] = (...payload) => dispatch({ type: name, payload });
return bound;
}, {});
}, []);
return [state, boundMethods];
}
module.exports = { useMethods };
One thing changed, and it changed everything: methods now enters the hook on exactly one line — the one that writes it into the box — and after that the hook never mentions it again. A dependency array can only be wrong about a value it can see, and the memo can no longer see the factory, so it has no opinion about how the caller wrote it. That is useLatest doing its one job: a box whose identity never changes, holding a value that changes constantly, so the code reading it is freed from the value's identity.
The reducer, meanwhile, is rebuilt on every render, and that costs nothing — React runs whichever reducer you handed it on the render where it processes the dispatch, so a fresh one is never the stale one. What matters is that it reads methodsRef.current rather than remembering a factory. Memoize this reducer without the box, which is the obvious next tidy-up, and it holds render 1's factory forever; that failure is in Gotchas, and it is a quiet one.
Try the same thing with useState and a few useCallbacks and you hit a wall you cannot climb without a ref. Build the method once — useCallback(() => setCount(count + 1), []) — and it closes over render 1's count, so it adds 1 to 0 forever. Give the callback the dependency it actually needs and its identity changes every time the state does, which was the whole thing you were buying. Stable or correct: pick one.
The reducer refuses that trade. state is not something a bound method closed over — it is an argument React passes in when it runs the reducer, and React always passes the newest one. So a method can be built on render 1, held by a mount-only effect for an hour, and still transition from the state as it is at the moment you call it. It never carried a state. It carries a name and dispatch, and neither of those ages.
That is the write-side escape hatch useGetState works through in detail: setCount(n => n + 1) is handed the freshest state, so writes are never stale even from a hopelessly old closure. useMethods is that same guarantee with a better surface. Every transition is an updater; the reducer is the thing that hands it the state.
Mount the counter from the prompt: useMethods(counterMethods, 0).
useRef(methods) builds the box and the next line fills it. The reducer is created — two lines that closed over methodsRef and nothing else. useReducer sets the state to 0 and returns a dispatch React promises never to replace. Then the memo body runs, for the only time in this component's life: methodsRef.current(0) returns { increment, decrement, add, reset }, Object.keys gives four names, and four dispatchers get built. add is now, permanently, (...payload) => dispatch({ type: 'add', payload }).add. As a prop on a React.memo child, or captured by a useEffect(..., []). Either way, whoever took it on render 1 holds the only add there will ever be.count is 4; four more renders happened. Each one repointed methodsRef.current at that render's factory, and the memo did nothing at all — same empty dependency array, same four functions handed back.add(5) fires. It dispatches { type: 'add', payload: [5] }. A name and an argument list; no state anywhere in it. React schedules a render.4, and that action. The reducer reads methodsRef.current — render 6's factory, the newest one there is — and calls it with 4, getting back { increment: () => 5, decrement: () => 3, add: (n) => 4 + n, reset: () => 0 }. Four closures over 4, built and thrown away inside one expression. It looks up add, calls it with 5, and gets 9.count is 9. The method that did it was built on render 1, when the count was 0, and has not been touched since.Be honest about the cost before reaching for this. For a counter with an increment and a decrement, useState and two arrow functions are shorter, and every reader of that component already knows how they work without opening a hook file. useMethods buys you an indirection between the click and the code that runs, and a factory that re-runs on every dispatch. Two transitions do not pay for that.
It starts paying when the transitions are many, related, and share one state shape:
setField, clearField, touch, reset, applyDefaults — five moves over one object, none of them meaningful alone. As useState handlers they are five things that must independently agree about the shape. As a factory they read as a list of what this state can do.play, pause, seek, finish. The transitions are the machine, and an object literal is a much better place to read one than a switch.counterMethods(3).add(2) is 5 with no React in the room. That is worth more than it sounds when the logic gets real.The tell is the same one that sends you to useReducer in the first place: the next state depends on the previous state in more than one way, and the ways are related to each other. useMethods does not change that answer — it deletes the ceremony you would have paid to act on it.
useMemo(..., [methods]) looks careful and is the one thing that breaks the hook's only promise, because the factory callers actually write is inline and changes identity every render. The methods rebuild every render, memo children re-render, and captured methods go orphaned. Fix: read the factory out of a ref and key the memo on nothing.useMemo(() => (state, action) => methods(state)[action.type](...action.payload), []) makes the reducer stable, which feels tidy, and now it holds render 1's factory forever. A transition that closes over a prop keeps reading render 1's prop: step goes 1, then 10, then 100, and bump() still adds 1. Fix: read methodsRef.current inside the reducer so it looks the factory up at dispatch time instead of remembering one.methods(initialState), read once. A factory that returns { next } while the state is idle and { next, cancel } while it is running gives you a cancel that never exists, because Object.keys ran when the state was idle. Fix: return every name from every state and let the transition itself decline — cancel: () => state. This is a real limitation of the shape, not a bug to route around.addItem: (item) => { state.items.push(item); return state; } hands back the object React already has, so Object.is matches and React skips the render — the list silently freezes. Fix: build a new one, { ...state, items: [...state.items, item] }. Same rule as any reducer.(state) => ({ doubleIt: () => count * 2 }), with count lifted from the component body, looks equivalent and mostly behaves. Then two methods fire in one click, React runs the reducer twice before re-rendering, and the second run still sees the count from the last render while state has already moved on. Fix: the parameter is the state. Nothing else is.useRef instead of useMemo for the bound object. React's docs are explicit that memoization is a performance hint, not a semantic guarantee — a future version may throw a useMemo cache away, and an identity you promised in your signature is precisely where that would hurt. Building the object into a ref on first use makes the guarantee yours rather than React's. Nothing breaks it today.useMethods at all — it offers useSetState for the merge-an-object case and points you back at useReducer for the rest. pelotom's use-methods — same name, different package — puts immer underneath, so a transition mutates a draft (addItem: (item) => { draft.items.push(item); }) and you still get a new object out; you trade a dependency for never writing another spread. Three answers to one question, and the disagreement is really about how much machinery a lookup table is worth.action.type, the state before and the state after gives you a transition log for nothing. That is the same property Redux devtools is built on, and it comes free the moment you stop scattering setState calls across a component.CreateMethods type pins every transition to (payload?: any), so add('hello') type-checks cleanly and blows up at runtime. Recovering each method's real parameters means mapping over ReturnType of the factory and lifting the Parameters of each entry — worth doing, and fiddlier than it looks.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
useMethods(methods, initialState) turns a map of named state transitions into ready-to-call action methods over a useReducer. You pass a factory — a function that takes the current state and returns an object of transitions, each returning the next state — and you get back the state plus one bound method per name. The caller writes increment(), never dispatch({ type: 'increment' }). It is useReducer with the action-type strings and the switch deleted.
function useMethods<S, M>(
methods: (state: S) => { [K in keyof M]: (...args: any[]) => S },
initialState: S,
): [S, { [K in keyof M]: (...args: any[]) => void }];
The bound methods object, and every method on it, keeps the same identity for the life of the component.
A counter. The factory takes the state and returns four transitions; you get four methods:
const counterMethods = (state) => ({
increment: () => state + 1,
decrement: () => state - 1,
add: (n) => state + n,
reset: () => 0,
});
const [count, { increment, add, reset }] = useMethods(counterMethods, 0);
add(5); // count is 5 on the next render
increment(); // 6
reset(); // 0
In real components the factory is written inline, so the transitions can close over props:
function Cart({ taxRate }) {
const [cart, { addItem, clear }] = useMethods(
(state) => ({
addItem: (item) => ({
items: [...state.items, item],
tax: state.tax + item.price * taxRate,
}),
clear: () => ({ items: [], tax: 0 }),
}),
{ items: [], tax: 0 },
);
// addItem is the same function on every render, so ItemList never
// re-renders just because Cart did.
return <ItemList items={cart.items} onAdd={addItem} onClear={clear} />;
}
React.memo children and grab them inside mount-only effects. This has to hold even though the factory above is a brand-new function on every render.methods(initialState). A factory that returns different keys for different states will not grow new methods later.add(5) and setField('email', value) both have to reach the transition intact.0, '', false and null all have to land.You'll take the switch out of a reducer, let the caller hand you the lookup table it always was, and wire one dispatching method per entry — built on the first render and never rebuilt.
A filter panel has eight things it can do to one piece of state: set the query, toggle a tag, clear the tags, pick a date range, reset everything. Write that with useReducer and you get eight case labels, eight action-type strings you have to spell identically in two places, and a switch that every new feature makes longer. Write it with useState and you get eight handlers plus a growing worry about which of them is reading a stale value. useMethods is the third option: write the eight transitions as an object literal, get eight methods back.
Look at what a reducer's switch actually does. It takes a string, finds the branch with that name, and runs that branch against the state. That is a lookup — find the thing called add, run it — written out as control flow. And JavaScript already has a lookup table: an object. So let the caller hand you an object whose keys are the names and whose values are the transitions, and the switch deletes itself; the reducer's whole body collapses to methods(state)[action.type](...). Nothing has been invented here. The table was always there, spelled one case at a time.
The shape falls out almost immediately. Build a reducer that does the lookup, then walk the method names once and hand back a dispatcher for each one.
const { useMemo, useReducer } = require('react');
function useMethods(methods, initialState) {
const reducer = useMemo(
() => (state, action) => methods(state)[action.type](...action.payload),
[methods],
);
const [state, dispatch] = useReducer(reducer, initialState);
const boundMethods = useMemo(() => {
const names = Object.keys(methods(initialState));
return names.reduce((bound, name) => {
bound[name] = (...payload) => dispatch({ type: name, payload });
return bound;
}, {});
}, [methods, initialState]);
return [state, boundMethods];
}
This is not a strawman. It is react-use's shipped useMethods, near enough line for line — and it transitions state correctly. Counting, adding, resetting, composing: all of it works.
What it gets wrong is the promise in the signature. Look at the second dependency array. [methods, initialState] rebuilds the memo whenever either one changes identity, so now write the factory where it belongs — inline, in the component body, where the transitions can close over props:
const [count, { increment }] = useMethods((s) => ({ increment: () => s + step }), 0);
That arrow is a new function on every render. The memo never hits again, so boundMethods and every function on it are rebuilt every render: the React.memo child you passed increment to re-renders on every parent render, and the mount-only effect that captured it is holding a function the hook no longer hands back. The stable identity evaporates for exactly the callers who needed it. And initialState is the same trap one notch quieter — useMethods(listMethods, []) passes a fresh array literal every render, so even a factory declared outside the component rebuilds.
const { useMemo, useReducer, useRef } = require('react');
function useMethods(methods, initialState) {
// ONE box for the life of the component, repointed at the newest factory on
// every render. This is the only line in the hook that reads `methods` —
// below it, nothing can be tied to the factory's identity, because nothing
// can see the factory.
const methodsRef = useRef(methods);
methodsRef.current = methods;
// The switch, deleted. Look the transition up by name in the object the
// factory returns, then call it with the arguments. `payload` is the whole
// argument list, which is why add(5) and setField('email', v) both survive.
const reducer = (state, action) =>
methodsRef.current(state)[action.type](...action.payload);
const [state, dispatch] = useReducer(reducer, initialState);
// One dispatching function per name, built on the first render and never
// again. The empty dependency array IS the stable-identity promise.
const boundMethods = useMemo(() => {
// Read once, off the initial state. That is a real limitation of the
// shape, not an oversight — see Gotchas.
const names = Object.keys(methodsRef.current(initialState));
return names.reduce((bound, name) => {
// `dispatch` is safe to close over: React guarantees its identity never
// changes. That guarantee is what lets this function be built once.
bound[name] = (...payload) => dispatch({ type: name, payload });
return bound;
}, {});
}, []);
return [state, boundMethods];
}
module.exports = { useMethods };
One thing changed, and it changed everything: methods now enters the hook on exactly one line — the one that writes it into the box — and after that the hook never mentions it again. A dependency array can only be wrong about a value it can see, and the memo can no longer see the factory, so it has no opinion about how the caller wrote it. That is useLatest doing its one job: a box whose identity never changes, holding a value that changes constantly, so the code reading it is freed from the value's identity.
The reducer, meanwhile, is rebuilt on every render, and that costs nothing — React runs whichever reducer you handed it on the render where it processes the dispatch, so a fresh one is never the stale one. What matters is that it reads methodsRef.current rather than remembering a factory. Memoize this reducer without the box, which is the obvious next tidy-up, and it holds render 1's factory forever; that failure is in Gotchas, and it is a quiet one.
Try the same thing with useState and a few useCallbacks and you hit a wall you cannot climb without a ref. Build the method once — useCallback(() => setCount(count + 1), []) — and it closes over render 1's count, so it adds 1 to 0 forever. Give the callback the dependency it actually needs and its identity changes every time the state does, which was the whole thing you were buying. Stable or correct: pick one.
The reducer refuses that trade. state is not something a bound method closed over — it is an argument React passes in when it runs the reducer, and React always passes the newest one. So a method can be built on render 1, held by a mount-only effect for an hour, and still transition from the state as it is at the moment you call it. It never carried a state. It carries a name and dispatch, and neither of those ages.
That is the write-side escape hatch useGetState works through in detail: setCount(n => n + 1) is handed the freshest state, so writes are never stale even from a hopelessly old closure. useMethods is that same guarantee with a better surface. Every transition is an updater; the reducer is the thing that hands it the state.
Mount the counter from the prompt: useMethods(counterMethods, 0).
useRef(methods) builds the box and the next line fills it. The reducer is created — two lines that closed over methodsRef and nothing else. useReducer sets the state to 0 and returns a dispatch React promises never to replace. Then the memo body runs, for the only time in this component's life: methodsRef.current(0) returns { increment, decrement, add, reset }, Object.keys gives four names, and four dispatchers get built. add is now, permanently, (...payload) => dispatch({ type: 'add', payload }).add. As a prop on a React.memo child, or captured by a useEffect(..., []). Either way, whoever took it on render 1 holds the only add there will ever be.count is 4; four more renders happened. Each one repointed methodsRef.current at that render's factory, and the memo did nothing at all — same empty dependency array, same four functions handed back.add(5) fires. It dispatches { type: 'add', payload: [5] }. A name and an argument list; no state anywhere in it. React schedules a render.4, and that action. The reducer reads methodsRef.current — render 6's factory, the newest one there is — and calls it with 4, getting back { increment: () => 5, decrement: () => 3, add: (n) => 4 + n, reset: () => 0 }. Four closures over 4, built and thrown away inside one expression. It looks up add, calls it with 5, and gets 9.count is 9. The method that did it was built on render 1, when the count was 0, and has not been touched since.Be honest about the cost before reaching for this. For a counter with an increment and a decrement, useState and two arrow functions are shorter, and every reader of that component already knows how they work without opening a hook file. useMethods buys you an indirection between the click and the code that runs, and a factory that re-runs on every dispatch. Two transitions do not pay for that.
It starts paying when the transitions are many, related, and share one state shape:
setField, clearField, touch, reset, applyDefaults — five moves over one object, none of them meaningful alone. As useState handlers they are five things that must independently agree about the shape. As a factory they read as a list of what this state can do.play, pause, seek, finish. The transitions are the machine, and an object literal is a much better place to read one than a switch.counterMethods(3).add(2) is 5 with no React in the room. That is worth more than it sounds when the logic gets real.The tell is the same one that sends you to useReducer in the first place: the next state depends on the previous state in more than one way, and the ways are related to each other. useMethods does not change that answer — it deletes the ceremony you would have paid to act on it.
useMemo(..., [methods]) looks careful and is the one thing that breaks the hook's only promise, because the factory callers actually write is inline and changes identity every render. The methods rebuild every render, memo children re-render, and captured methods go orphaned. Fix: read the factory out of a ref and key the memo on nothing.useMemo(() => (state, action) => methods(state)[action.type](...action.payload), []) makes the reducer stable, which feels tidy, and now it holds render 1's factory forever. A transition that closes over a prop keeps reading render 1's prop: step goes 1, then 10, then 100, and bump() still adds 1. Fix: read methodsRef.current inside the reducer so it looks the factory up at dispatch time instead of remembering one.methods(initialState), read once. A factory that returns { next } while the state is idle and { next, cancel } while it is running gives you a cancel that never exists, because Object.keys ran when the state was idle. Fix: return every name from every state and let the transition itself decline — cancel: () => state. This is a real limitation of the shape, not a bug to route around.addItem: (item) => { state.items.push(item); return state; } hands back the object React already has, so Object.is matches and React skips the render — the list silently freezes. Fix: build a new one, { ...state, items: [...state.items, item] }. Same rule as any reducer.(state) => ({ doubleIt: () => count * 2 }), with count lifted from the component body, looks equivalent and mostly behaves. Then two methods fire in one click, React runs the reducer twice before re-rendering, and the second run still sees the count from the last render while state has already moved on. Fix: the parameter is the state. Nothing else is.useRef instead of useMemo for the bound object. React's docs are explicit that memoization is a performance hint, not a semantic guarantee — a future version may throw a useMemo cache away, and an identity you promised in your signature is precisely where that would hurt. Building the object into a ref on first use makes the guarantee yours rather than React's. Nothing breaks it today.useMethods at all — it offers useSetState for the merge-an-object case and points you back at useReducer for the rest. pelotom's use-methods — same name, different package — puts immer underneath, so a transition mutates a draft (addItem: (item) => { draft.items.push(item); }) and you still get a new object out; you trade a dependency for never writing another spread. Three answers to one question, and the disagreement is really about how much machinery a lookup table is worth.action.type, the state before and the state after gives you a transition log for nothing. That is the same property Redux devtools is built on, and it comes free the moment you stop scattering setState calls across a component.CreateMethods type pins every transition to (payload?: any), so add('hello') type-checks cleanly and blows up at runtime. Recovering each method's real parameters means mapping over ReturnType of the factory and lifting the Parameters of each entry — worth doing, and fiddlier than it looks.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.