Build a custom React hook that behaves like useState, with one twist: every value on its way into state first passes through a mediator function you supply. The mediator is the single chokepoint where you sanitize, clamp, or normalize a write — strip non-digits from a phone field, cap a number at a ceiling, uppercase a code. Instead of scattering that cleanup across every place that calls the setter, you define it once and the hook applies it to every write automatically.
function useMediatedState<T>(
mediator: (newValue: any, prevState: T) => T,
initialState: T
): [T, (value: any) => void];
The setter accepts either a plain value or a functional updater (prev) => next. The functional updater is resolved against the previous state first, and the result is then passed through mediator. The initialState is stored as-is and is not mediated.
function PhoneField() {
const digitsOnly = (v) => String(v).replace(/\D/g, '');
const [phone, setPhone] = useMediatedState(digitsOnly, '');
return (
<input
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
);
}
// Typing "(415) 555-0199" stores "4155550199" — the mediator runs on every keystroke.
// A clamping mediator caps every committed value at 10.
const clampTo10 = (n) => Math.min(n, 10);
const [n, setN] = useMediatedState(clampTo10, 0);
setN(5); // n === 5
setN(42); // n === 10 (clamped on the way in)
// A functional updater is resolved first, then mediated.
const stripLetters = (v) => String(v).replace(/\D/g, '');
const [code, setCode] = useMediatedState(stripLetters, '12');
setCode((prev) => prev + 'x4'); // prev '12' + 'x4' = '12x4' → mediated to '124'
(newValue, prevState), so it can make decisions relative to what is already stored (reject a decrease, merge, dedupe).You'll wrap useState so that one function — the mediator — guards the only door into state, transforming every value the moment it is written.
A phone field should only ever hold digits. A quantity should never exceed the number in stock. A coupon code is always uppercase. With plain useState, you enforce these rules at every call site: clean the value before setPhone, clamp before setQuantity, uppercase before setCode. Miss one spot and a bad value slips in. useMediatedState moves that rule to a single place — the mediator — and runs it on every write, so no caller can ever store an unclean value, no matter how they call the setter.
Think of the mediator as a turnstile in front of state. useState lets any value walk straight in. useMediatedState puts a turnstile at the door: every value — whether it came from a plain setState(v) call or from resolving a functional updater — has to pass through mediator(value, prevState) first, and only the value the turnstile hands back is what gets stored. The state behind the door is therefore always "clean" by construction, because nothing reaches it without being mediated.
The obvious move is to call useState and hand back its setter directly — after all, we just want a setter that stores things:
const { useState } = require('react');
function useMediatedState(mediator, initialState) {
const [state, setState] = useState(initialState);
const setMediatedState = (value) => {
setState(value); // commit the raw value
};
return [state, setMediatedState];
}
This stores whatever it is given, untouched. The whole point of the hook — running mediator — never happens. Call setMediatedState('a1b2c3') and state becomes 'a1b2c3', letters and all, instead of '123'. A tempting "fix" is to mediate on the way out instead — return [mediator(state), setMediatedState] — but that re-runs the mediator on already-clean state every render, and it has no raw value to clean anyway because the bad value was already stored. The mediator has to run at the moment of writing, not reading.
const { useState } = require('react');
function useMediatedState(mediator, initialState) {
// initialState is stored as-is — it does NOT pass through the mediator.
const [state, setState] = useState(initialState);
const setMediatedState = (value) => {
// Always use the functional form of setState so we get the freshest
// pending state (prev), which matters when several writes batch together.
setState((prev) => {
// If the caller passed a functional updater, resolve it against prev
// FIRST to get the raw next value; otherwise the value is already raw.
const next = typeof value === 'function' ? value(prev) : value;
// Then run the raw value through the mediator — this is the single
// chokepoint, and prev is available to it for relative decisions.
return mediator(next, prev);
});
};
return [state, setMediatedState];
}
module.exports = { useMediatedState };
Three shifts turn the naive version into the working one. First, the setter no longer commits value; it commits mediator(next, prev), so the rule runs on every write. Second, we mediate inside the functional updater, so prev is always the latest pending state — batched writes stack correctly and the mediator can compare against what is already stored. Third, a functional updater is resolved before mediation: we call value(prev) to get the raw next value, then mediate that, which keeps the two responsibilities — "what does the caller want next" and "is it allowed" — cleanly separated.
The key shift from naive to working is where the mediator runs: on write, inside the updater, against the previous state — never on read.
Start with useMediatedState(digitsOnly, '12'), where digitsOnly = (v) => String(v).replace(/\D/g, ''). The first render stores '12' verbatim — the initial state is never mediated — so state is '12'.
Now a handler calls setMediatedState((prev) => prev + 'x4'):
setState with a function. React invokes that function with the latest pending state, which is '12' — that's prev.value is itself a function, so typeof value === 'function' is true. We resolve it: value('12') returns the raw string '12x4'. That's next.mediator('12x4', '12') strips the non-digit, returning '124'.'124'. On the next render, state is '124'.Had two writes batched in one event — say setMediatedState((p) => p + '2a') then setMediatedState((p) => p + '3b') starting from '1' — the first updater sees prev = '1', produces raw '12a', mediates to '12'; the second sees prev = '12', produces '123b', mediates to '123'. Each write reads the freshest state because the work happens inside the functional updater.
setState(value) stores whatever the caller passed and skips the mediator entirely, so bad values slip through. Fix: commit mediator(next, prev), never the raw value.mediator(state) from the hook re-runs the mediator every render on already-clean state, breaks functional updaters (there is no raw value left to clean), and is wasted work. Fix: mediate once, at write time, inside the setter.value outside the updater. Computing const next = typeof value === 'function' ? value(state) : value against the render-time state uses a stale snapshot, so two batched writes both see the same old state and one is lost. Fix: resolve the updater against prev inside setState((prev) => ...).initialState through the mediator surprises callers who expect their seed value stored verbatim, and it runs the mediator before any write happened. Fix: hand initialState straight to useState.useState accepts an initializer function for expensive seeds; you could let initialState be a function too, calling it once on mount — while still keeping it un-mediated.{ value, error } pair) would let the hook surface validation messages, not just sanitized values — the shape of react-use's useStateValidator.useCallback (with mediator in the deps) keeps its reference stable so it can be safely passed to memoized children.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 behaves like useState, with one twist: every value on its way into state first passes through a mediator function you supply. The mediator is the single chokepoint where you sanitize, clamp, or normalize a write — strip non-digits from a phone field, cap a number at a ceiling, uppercase a code. Instead of scattering that cleanup across every place that calls the setter, you define it once and the hook applies it to every write automatically.
function useMediatedState<T>(
mediator: (newValue: any, prevState: T) => T,
initialState: T
): [T, (value: any) => void];
The setter accepts either a plain value or a functional updater (prev) => next. The functional updater is resolved against the previous state first, and the result is then passed through mediator. The initialState is stored as-is and is not mediated.
function PhoneField() {
const digitsOnly = (v) => String(v).replace(/\D/g, '');
const [phone, setPhone] = useMediatedState(digitsOnly, '');
return (
<input
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
);
}
// Typing "(415) 555-0199" stores "4155550199" — the mediator runs on every keystroke.
// A clamping mediator caps every committed value at 10.
const clampTo10 = (n) => Math.min(n, 10);
const [n, setN] = useMediatedState(clampTo10, 0);
setN(5); // n === 5
setN(42); // n === 10 (clamped on the way in)
// A functional updater is resolved first, then mediated.
const stripLetters = (v) => String(v).replace(/\D/g, '');
const [code, setCode] = useMediatedState(stripLetters, '12');
setCode((prev) => prev + 'x4'); // prev '12' + 'x4' = '12x4' → mediated to '124'
(newValue, prevState), so it can make decisions relative to what is already stored (reject a decrease, merge, dedupe).You'll wrap useState so that one function — the mediator — guards the only door into state, transforming every value the moment it is written.
A phone field should only ever hold digits. A quantity should never exceed the number in stock. A coupon code is always uppercase. With plain useState, you enforce these rules at every call site: clean the value before setPhone, clamp before setQuantity, uppercase before setCode. Miss one spot and a bad value slips in. useMediatedState moves that rule to a single place — the mediator — and runs it on every write, so no caller can ever store an unclean value, no matter how they call the setter.
Think of the mediator as a turnstile in front of state. useState lets any value walk straight in. useMediatedState puts a turnstile at the door: every value — whether it came from a plain setState(v) call or from resolving a functional updater — has to pass through mediator(value, prevState) first, and only the value the turnstile hands back is what gets stored. The state behind the door is therefore always "clean" by construction, because nothing reaches it without being mediated.
The obvious move is to call useState and hand back its setter directly — after all, we just want a setter that stores things:
const { useState } = require('react');
function useMediatedState(mediator, initialState) {
const [state, setState] = useState(initialState);
const setMediatedState = (value) => {
setState(value); // commit the raw value
};
return [state, setMediatedState];
}
This stores whatever it is given, untouched. The whole point of the hook — running mediator — never happens. Call setMediatedState('a1b2c3') and state becomes 'a1b2c3', letters and all, instead of '123'. A tempting "fix" is to mediate on the way out instead — return [mediator(state), setMediatedState] — but that re-runs the mediator on already-clean state every render, and it has no raw value to clean anyway because the bad value was already stored. The mediator has to run at the moment of writing, not reading.
const { useState } = require('react');
function useMediatedState(mediator, initialState) {
// initialState is stored as-is — it does NOT pass through the mediator.
const [state, setState] = useState(initialState);
const setMediatedState = (value) => {
// Always use the functional form of setState so we get the freshest
// pending state (prev), which matters when several writes batch together.
setState((prev) => {
// If the caller passed a functional updater, resolve it against prev
// FIRST to get the raw next value; otherwise the value is already raw.
const next = typeof value === 'function' ? value(prev) : value;
// Then run the raw value through the mediator — this is the single
// chokepoint, and prev is available to it for relative decisions.
return mediator(next, prev);
});
};
return [state, setMediatedState];
}
module.exports = { useMediatedState };
Three shifts turn the naive version into the working one. First, the setter no longer commits value; it commits mediator(next, prev), so the rule runs on every write. Second, we mediate inside the functional updater, so prev is always the latest pending state — batched writes stack correctly and the mediator can compare against what is already stored. Third, a functional updater is resolved before mediation: we call value(prev) to get the raw next value, then mediate that, which keeps the two responsibilities — "what does the caller want next" and "is it allowed" — cleanly separated.
The key shift from naive to working is where the mediator runs: on write, inside the updater, against the previous state — never on read.
Start with useMediatedState(digitsOnly, '12'), where digitsOnly = (v) => String(v).replace(/\D/g, ''). The first render stores '12' verbatim — the initial state is never mediated — so state is '12'.
Now a handler calls setMediatedState((prev) => prev + 'x4'):
setState with a function. React invokes that function with the latest pending state, which is '12' — that's prev.value is itself a function, so typeof value === 'function' is true. We resolve it: value('12') returns the raw string '12x4'. That's next.mediator('12x4', '12') strips the non-digit, returning '124'.'124'. On the next render, state is '124'.Had two writes batched in one event — say setMediatedState((p) => p + '2a') then setMediatedState((p) => p + '3b') starting from '1' — the first updater sees prev = '1', produces raw '12a', mediates to '12'; the second sees prev = '12', produces '123b', mediates to '123'. Each write reads the freshest state because the work happens inside the functional updater.
setState(value) stores whatever the caller passed and skips the mediator entirely, so bad values slip through. Fix: commit mediator(next, prev), never the raw value.mediator(state) from the hook re-runs the mediator every render on already-clean state, breaks functional updaters (there is no raw value left to clean), and is wasted work. Fix: mediate once, at write time, inside the setter.value outside the updater. Computing const next = typeof value === 'function' ? value(state) : value against the render-time state uses a stale snapshot, so two batched writes both see the same old state and one is lost. Fix: resolve the updater against prev inside setState((prev) => ...).initialState through the mediator surprises callers who expect their seed value stored verbatim, and it runs the mediator before any write happened. Fix: hand initialState straight to useState.useState accepts an initializer function for expensive seeds; you could let initialState be a function too, calling it once on mount — while still keeping it un-mediated.{ value, error } pair) would let the hook surface validation messages, not just sanitized values — the shape of react-use's useStateValidator.useCallback (with mediator in the deps) keeps its reference stable so it can be safely passed to memoized children.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.