Inside my React component, I have these:
const [vendor,setVendor] = useState("");
const [taggedWith,setTaggedWith] = useState("");
function updateQuery () {
const filters = [];
if(vendor) {
filters.push({
label: `Vendor: ${vendor}`
})
}
if(taggedWith) {
filters.push({
label: `Tagged with: ${taggedWith}`
})
}
props.onUpdate(filters);
}
function debounce (func,delay){
let timer;
return function () {
clearTimeout(timer);
timer = setTimeout(()=>{
func();
},delay);
};
};
const updateQueryWithDebounce = useCallback(debounce(updateQuery,300),[]);
useEffect(()=>{
updateQueryWithDebounce();
},[taggedWith,vendor]);
Debouncing works, but the problem is, the stateful variables inside updateQuery function stays the same, because of useCallback. If I pass those states to the dependency array of useCallback, the debouncing function gets redeclared at every render, thus, new function with its closure is created which leads to debouncing not working. How can I fix that ?
You can use a ref to store the timer handle, so you can cancel the previous update when either state variable changes:
const [vendor,setVendor] = useState("");
const [taggedWith,setTaggedWith] = useState("");
const updateRef = useRef(0);
useEffect(() => {
updateRef.current = setTimeout(() => {
updateRef.current = 0;
const filters = [];
if (vendor) {
filters.push({
label: `Vendor: ${vendor}`
});
}
if (taggedWith) {
filters.push({
label: `Tagged with: ${taggedWith}`
});
}
props.onUpdate(filters);
}, 300);
return () => { // Cleanup callback
clearTmeout(updateRef.current);
updateRef.current = 0;
};
}, [taggedWith, vendor]);
Some of that code can be isolated out the component entirely:
const buildFilters = (taggedWith, vendor) => {
const filters = [];
if (vendor) {
filters.push({
label: `Vendor: ${vendor}`
});
}
if (taggedWith) {
filters.push({
label: `Tagged with: ${taggedWith}`
});
}
return filters;
};
Then the body of the component becomes:
const [vendor,setVendor] = useState("");
const [taggedWith,setTaggedWith] = useState("");
const updateRef = useRef(0);
useEffect(() => {
updateRef.current = setTimeout(() => {
updateRef.current = 0;
props.onUpdate(buildFilters(taggedWith, vendor));
}, 300);
return () => { // Cleanup callback
clearTmeout(updateRef.current);
updateRef.current = 0;
};
}, [taggedWith, vendor]);
Note: All of the above assumes that props.onUpdate
is guaranteed to be a stable function (like the setters from useState
are). If it isn't, things are more complicated, because you have to add it to the dependencies list and then handle the possibility that onUpdate
has changed but taggedWith
and vendor
haven't.
You could probably even wrap that debouncing logic in a hook (and have the hook handle an unstable callback). Here's a fairly off-the-cuff example:
const useDebounce = (fn, ms, deps) => {
const ref = useRef(null);
if (!ref.current) {
// One-time init
ref.current = {
timer: 0,
};
}
// Always remember the most recent `fn` on our ref object
ref.current.fn = fn;
useEffect(() => {
ref.current.timer = setTimeout(() => {
ref.current.timer = 0;
// Always use the most recent `fn`, not necessarily
// the one we had when scheduling the timer
ref.current.fn.call(); // `call` so we don't pass our ref obj as `this`
}, ms);
return () => {
clearTimeout(ref.current.timer);
ref.current.timer = 0;
};
}, deps);
};
Then the component code (using buildFilters
) looks like this:
const [vendor, setVendor] = useState("");
const [taggedWith, setTaggedWith] = useState("");
useDebounce(
() => {
props.onUpdate(buildFilters(taggedWith, vendor));
},
300,
[taggedWith, vendor]
);
Live Example:
const { useState, useRef, useEffect } = React;
const useDebounce = (fn, ms, deps) => {
const ref = useRef(null);
if (!ref.current) {
// One-time init
ref.current = {
timer: 0,
};
}
// Always remember the most recent `fn` on our ref object
ref.current.fn = fn;
useEffect(() => {
ref.current.timer = setTimeout(() => {
ref.current.timer = 0;
// Always use the most recent `fn`, not necessarily
// the one we had when scheduling the timer
ref.current.fn.call(); // `call` so we don't pass our ref obj as `this`
}, ms);
return () => {
clearTimeout(ref.current.timer);
ref.current.timer = 0;
};
}, deps);
};
const buildFilters = (taggedWith, vendor) => {
const filters = [];
if (vendor) {
filters.push({
label: `Vendor: ${vendor}`
});
}
if (taggedWith) {
filters.push({
label: `Tagged with: ${taggedWith}`
});
}
return filters;
};
const Example = (props) => {
const [vendor, setVendor] = useState("");
const [taggedWith, setTaggedWith] = useState("");
useDebounce(
() => {
props.onUpdate(buildFilters(taggedWith, vendor));
},
300,
[taggedWith, vendor]
);
return <div>
<label>
<input type="text" value={vendor} onChange={
({target: {value}}) => { setVendor(value); }
} />
</label>
<label>
<input type="text" value={taggedWith} onChange={
({target: {value}}) => { setTaggedWith(value); }
} />
</label>
</div>;
};
const App = () => {
const [filters, setFilters] = useState([]);
return <div>
<Example onUpdate={setFilters} />
<div>
Filters ({filters.length}):
{!!filters.length &&
<ul>
{filters.map(filter => <li key={filter.label}>{filter.label}</li>)}
</ul>
}
</div>
</div>;
};
ReactDOM.render(<App />, document.getElementById("root"));
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.development.js"></script>
I can't claim that hook is thoroughly tested, but it does seem to work even when the callback is unstable:
const { useState, useRef, useEffect } = React;
const useDebounce = (fn, ms, deps) => {
const ref = useRef(null);
if (!ref.current) {
// One-time init
ref.current = {
timer: 0,
};
}
// Always remember the most recent `fn` on our ref object
ref.current.fn = fn;
useEffect(() => {
ref.current.timer = setTimeout(() => {
ref.current.timer = 0;
// Always use the most recent `fn`, not necessarily
// the one we had when scheduling the timer
ref.current.fn.call(); // `call` so we don't pass our ref obj as `this`
}, ms);
return () => {
clearTimeout(ref.current.timer);
ref.current.timer = 0;
};
}, deps);
};
const buildFilters = (taggedWith, vendor) => {
const filters = [];
if (vendor) {
filters.push({
label: `Vendor: ${vendor}`
});
}
if (taggedWith) {
filters.push({
label: `Tagged with: ${taggedWith}`
});
}
return filters;
};
const Example = (props) => {
const [vendor, setVendor] = useState("");
const [taggedWith, setTaggedWith] = useState("");
useDebounce(
() => {
console.log(`filter update ${vendor} ${taggedWith}`);
props.onUpdate(buildFilters(taggedWith, vendor));
},
300,
[taggedWith, vendor]
);
return <div>
<label>
<input type="text" value={vendor} onChange={
({target: {value}}) => { setVendor(value); }
} />
</label>
<label>
<input type="text" value={taggedWith} onChange={
({target: {value}}) => { setTaggedWith(value); }
} />
</label>
</div>;
};
const App = () => {
const [counter, setCounter] = useState(0);
const [filters, setFilters] = useState([]);
const ref = useRef(null);
if (!ref.current) {
ref.current = {};
}
useEffect(() => {
const timer = setInterval(() => setCounter(c => c + 1), 100);
return () => {
clearInterval(timer);
};
}, []);
// An update callback that we intentionally recreate every render
// to check that useDebounce handles using the **latest** function
const onUpdate = ref.current.onUpdate = function onUpdate(filters) {
if (onUpdate !== ref.current.onUpdate) {
console.log("STALE FUNCTION CALLED");
}
setFilters(filters);
};
return <div>
Counter: {counter}
<Example onUpdate={onUpdate} />
<div>
Filters ({filters.length}):
{!!filters.length &&
<ul>
{filters.map(filter => <li key={filter.label}>{filter.label}</li>)}
</ul>
}
</div>
</div>;
};
ReactDOM.render(<App />, document.getElementById("root"));
<div id="root"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.development.js"></script>