In React development, hooks like useState
, useEffect
, useMemo
, and useCallback
are essential for managing state,side effects, optimizing performance, and memoizing functions. However, understanding how they work and implementing them effectively can sometimes be challenging. In this blog, we’ll explore polyfills for these built-in hooks and delve into custom hooks like useDebounce
and useThrottle
. We’ll demonstrate how these hooks operate and how you can implement them from scratch to enhance your React applications.
Polyfill of useStateHook
Let's begin by examining the functionality of useState
through an example:
import React, { useState, useEffect } from 'react';
const Component = () => {
const [counter, setCounter] = useState(0); // Non-lazy initialization of state value
const [theme, setTheme] = useState(() => {
return localStorage.getItem("theme") || "light";
}); // Lazy initialization of state value
// Changing state values
const toggleTheme = () => setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
const increment = () => setCounter((prevCounter) => prevCounter + 1);
const decrement = () => setCounter((prevCounter) => prevCounter - 1);
useEffect(() => {
localStorage.setItem("theme", theme);
}, [theme]);
return (
<>
<p onClick={toggleTheme}>Theme:{theme}</p>
<div>
<button onClick={increment}>+</button>
<p>{counter}</p>
<button onClick={decrement}>-</button>
</div>
</>
)
}
export default Component;
1. What does it take?
useState
takes an initial state value. This can either be:A direct value (e.g.,
0
,""
,false
).A function that returns the initial value (for lazy initialization).
2. How does it perform?
Non-lazy initialization: If you pass a value directly,
useState
initializes the state during the first render with that value.Lazy initialization: If you pass a function, it will only be executed on the first render to determine the initial state value. This is useful when the initial value depends on computations or external sources like
localStorage
or API calls.
3. What does it return?
useState
returns an array with two values:Current state value: The current value of the state.
State setter function: A function used to update the state.
As we have understood how useState
works, let's now proceed to implement the useStatePolyfill
:
let numberOfStatesInitialized = 0;
const states = [];
const useStatePolyfill = (initialState) => {
const stateID = numberOfStatesInitialized++; // Unique state ID
const [, dispatch] = useReducer(() => ({})); // Trigger rerender with dispatch
// Return existing state if already initialized. This is helpful in case of rerendering
if (states[stateID]) {
return states[stateID];
}
// Create setState function to update state
const setState = (newState) => {
if (typeof newState === "function") {
newState = newState(states[stateID][0]);
}
const isValueChanged = !Object.is(states[stateID][0], newState);
// Only trigger rerender if the value changes
if (isValueChanged) {
states[stateID][0] = newState;
numberOfStatesInitialized = 0;
// We are initializing the numberOfStatesInitialized to zero because upon re-render, this
// value will not automatically reset to 0. If we do not reset it, the
// previously declared states will be reinitialized, and the values will not be retained.
dispatch({}); // Trigger re-render
}
}
// Initialize state
states[stateID] = [initialState, setState];
return states[stateID];
}
export default useStatePolyfill;
Now, let's apply our useStatePolyfill
to the same example and observe the results:
import React, { useEffect } from 'react';
import useStatePolyfill from './hooks/useStatePolyfill';
const Component = () => {
const [counter, setCounter] = useStatePolyfill(0); // Non-lazy initialization of state value
const [theme, setTheme] = useStatePolyfill(() => {
return localStorage.getItem("theme") || "light";
}); // Lazy initialization of state value
// Changing state values
const toggleTheme = () => setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
const increment = () => setCounter((prevCounter) => prevCounter + 1);
const decrement = () => setCounter((prevCounter) => prevCounter - 1);
useEffect(() => {
localStorage.setItem("theme", theme);
}, [theme]);
return (
<>
<p onClick={toggleTheme}>Theme:{theme}</p>
<div>
<button onClick={increment}>+</button>
<p>{counter}</p>
<button onClick={decrement}>-</button>
</div>
</>
)
}
export default Component;
Polyfill of useEffectHook
Let's begin by examining the functionality of useEffect
through an example:
import React, { useState, useEffect } from 'react';
const Counter = () => {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('Component has mounted!');
// Cleanup function (optional), runs when the component is unmounted
return () => {
console.log('Component has unmounted!');
};
}, []); // Empty dependency array ensures it runs only once when the component mounts
useEffect(() => {
console.log(`Count has been updated to: ${count}`);
// Optionally, cleanup function can be returned here (for future renders or unmounting)
return () => {
console.log('Cleanup before the next render or unmounting!');
};
}, [count]); // Runs when 'count' changes
useEffect(() => {
console.log('Component rendered or state changed!');
// Optionally, cleanup function can be returned here (for future renders or unmounting)
return () => {
console.log('Cleanup before the next render or unmounting!');
};
}); // No dependencies means this runs on every render
return (
<div>
<h1>Count: {count}</h1>
<button onClick={() => setCount(count + 1)}>Increment Count</button>
</div>
);
};
export default Counter;
1. What Does It Take?
A callback function that contains the side effect logic.
An optional dependency array that determines when the effect should run (if omitted, the effect runs on every render).
2. What Does It Determine?
- It determines when the side effect (e.g., data fetching, subscriptions, or DOM manipulation) should be executed, based on changes in the component's lifecycle or state/props dependencies.
3. How Does It Perform?
useEffect
runs after every render (or when the dependencies change, if provided), allowing for asynchronous operations or side effects without blocking the render process. It helps manage resources efficiently and can clean up resources on unmount using the cleanup function.
The Three Key Conditions to Remember When Using useEffect
:
No Dependencies (
useEffect(callback)
):- The effect runs on every render, which may lead to unnecessary executions and inefficiency.
Empty Dependency Array (
useEffect(callback, [])
):- The effect runs only once on the initial render and never again, providing stability throughout the component's lifecycle.
With Dependencies (
useEffect(callback, [dependencies])
):- The effect runs only when the specified dependencies change, allowing fine-grained control over when side effects are triggered.
As we have understood how useEffect
works, let's now proceed to implement the useEffectPolyfill
:
import { useRef } from 'react';
const useEffectPolyfill = (callback, dependencies) => {
const effectRef = useRef({
onMountExecuted: false,
dependencies: undefined,
cleanup: undefined,
});
// First Mount Executed
if(!effectRef.current.onMountExecuted) {
if(dependencies && Array.isArray(dependencies)) {
effectRef.current.dependencies = dependencies;
}
const cleanup = callback();
if(cleanup && typeof cleanup === "function") {
effectRef.current.cleanup = cleanup;
}
} else if(dependencies && Array.isArray(dependencies)) {
// Dependencies Changed
const hasDependenciesChange = !deepCheck(
effectRef.current.dependencies,
dependencies
);
// Whenever dependencies change execute cleanup returned by previous callback execution
if(effectRef.current.cleanup) {
effectRef.current.cleanup();
}
if(hasDependenciesChange) {
effectRef.current.dependencies = dependencies;
const cleanup = callback();
if(cleanup && typeof cleanup === "function") {
effectRef.current.cleanup = cleanup;
}
}
} else {
// Dependencies Undefined
effectRef.current.dependencies = undefined;
// Whenever rerender happens execute cleanup returned by previous callback execution
if(effectRef.current.cleanup) {
effectRef.current.cleanup();
}
const cleanup = callback();
if(cleanup && typeof cleanup === "function") {
effectRef.current.cleanup = cleanup;
}
}
};
const deepCheck = (obj1, obj2) => {
return JSON.stringify(obj1) === JSON.stringify(obj2);
};
export default useEffectPolyfill;
/* This implementation is close enough too useEffect of React but you can see when there
is unmounting of component permanently that is not been detected by us due to which
cleanup execution when component unmounts is not shown here. */
Now, let's apply our useEffectPolyfill
to the same example and observe the results:
import React, { useState } from 'react';
import useEffectPolyfill from './hooks/useEffectPolyfill';
const Counter = () => {
const [count, setCount] = useState(0);
useEffectPolyfill(() => {
console.log("Component has mounted");
return () => {
console.log("Component has unmounted");
}
}, []);
useEffectPolyfill(() => {
console.log(`Count has been updated to : ${count}`);
return () => {
console.log(`Coumponent has cleanup before next render or mounting`);
}
}, [count]);
useEffectPolyfill(() => {
console.log(`Coumponent has rendered`);
return () => {
console.log(`Coumponent has cleanup before next render or mounting`);
}
});
return (<div>
<p > Count: $ {count} </p>
<button onClick={() => setCount(count + 1)} > Increment Count </button>
</div>);
}
Polyfill of useCallbackHook
Let's begin by examining the functionality of useCallback
through an example:
import React, { useState, useCallback, memo } from 'react';
// Child component that accepts a value and a callback to change a specific part of the state
const Input = memo(({ value, onChange, name }) => {
console.log(`${name} rendered`); // To show whether the child re-renders
return (
<div>
<label>{name}: </label>
<input
type="number"
value={value}
onChange={(e) => onChange(name, Number(e.target.value))}
/>
</div>
);
});
const App = () => {
// Store both states in one object
const [state, setState] = useState({ state1: 0, state2: 0 });
// Memoized callback to change a specific state field
const changeState = useCallback((name, newValue) => {
setState((prevState) => ({
...prevState,
[name]: newValue, // Update the specific state key
}));
}, []); // Empty dependency array means this function is created only once on first mount
// Uncomment the following line to test the behavior without useCallback
// const changeState = (name, newValue) => setState(prevState => ({ ...prevState, [name]: newValue }));
return (
<div>
<h1>Parent Component</h1>
<Input value={state.state1} onChange={changeState} name='state1' />
<Input value={state.state2} onChange={changeState} name='state2' />
<div>
<p>State 1: {state.state1}</p>
<p>State 2: {state.state2}</p>
</div>
</div>
);
};
export default App;
When you remove useCallback
and change any one state you will see both inputs will re-render because on re-render the function changeState
is recreated and reference changes in Input
component.
1. What Does It Take?
useCallback
takes two arguments:A callback function to memoize.
A dependency array that determines when the function should be recreated.
2. What Does It Return?
- It returns a memoized version of the provided callback function, which will only be recreated based on the dependencies specified.
3. How Does It Perform?
useCallback
helps optimize performance by avoiding unnecessary function re-creations on every render, thus reducing unnecessary re-renders of child components that depend on the function.
The three key Conditions to remember when using useCallback
:
No Dependencies (
useCallback(callback)
):- The callback is recreated on every render, making
useCallback
ineffective.
- The callback is recreated on every render, making
Empty Dependency Array (
useCallback(callback, [])
):- The callback is created only once on the first render and remains stable throughout the component lifecycle.
With Dependencies (
useCallback(callback, [dependencies])
):- The callback is recreated only when the specified dependencies change.
As we have understood how useCallback
works, let's now proceed to implement the useCallbackPolyfill
:
import { useRef } from 'react';
const useCallbackPolyfill = (callback, dependencies) => {
const callbackRef = useRef({
callback: undefined,
dependencies: undefined,
});
// Initially dependencies are undefined and callback too so dependencies is considered as changed
if (dependencies && Array.isArray(dependencies)) {
const hasDependenciesChange = !deepCheck(callbackRef.current.dependencies, dependencies);
if (hasDependenciesChange) {
if (typeof callback === "function") {
callbackRef.current.callback = callback;
} else {
throw new TypeError("Callback is not function");
}
callbackRef.current.dependencies = dependencies;
}
} else {
if (typeof callback === 'function') {
callbackRef.current.callback = callback;
} else {
throw new TypeError('Callback is not function');
}
}
return callbackRef.current.callback;
}
const deepCheck = (obj1, obj2) => {
return JSON.stringify(obj1) === JSON.stringify(obj2);
};
export default useCallbackPolyfill;
Now, let's apply our useCallbackPolyfill
to the same example and observe the results:
import React, { useState, memo } from 'react';
import useCallbackPolyfill from './hooks/useCallbackPolyfill';
const Input = memo(({ value, onChange, name }) => {
console.log(`${name} rendered`);
return (
<div>
<label>{name}: </label>
<input
type='number'
value={value}
onChange={e => onChange(name, Number(e.target.value))}
/>
</div>
);
});
const App = () => {
const [state, setState] = useState({ state1: 0, state2: 0 });
const changeState = useCallbackPolyfill((name, newValue) => {
setState(prevState => ({
...prevState,
[name]: newValue,
}));
}, []);
return (
<div>
<h1>Parent Component</h1>
<Input value={state.state1} onChange={changeState} name='state1' />
<Input value={state.state2} onChange={changeState} name='state2' />
</div>
);
};
export default App;
Polyfill of useMemoHook
Let's begin by examining the functionality of useMemo
through an example:
import React, { memo, useState, useMemo, useCallback } from 'react';
const Input = memo(({ value, onChange }) => {
console.log('Input rendered');
return (
<input
type='text'
value={value}
onChange={onChange}
placeholder='Type something to re-render parent'
/>
);
});
const Button = memo(({ onClick }) => {
console.log('Button rendered');
return <button onClick={onClick}>Add Random Number</button>;
});
const Sum = memo(({ sum }) => {
console.log('Sum rendered');
return (
<div>
<h2>Sum of Numbers: {sum}</h2>
</div>
);
});
const MemoizedSumComponent = () => {
const [numbers, setNumbers] = useState([1, 2, 3, 4, 5]);
const [inputValue, setInputValue] = useState('');
const sum = useMemo(() => {
console.log('Recalculating sum...');
return numbers.reduce((acc, num) => acc + num, 0);
}, [numbers]);
//Whenever numbers change recalculation happens else only it calculates only on first mount
// Uncomment the following line to test the behavior without useMemo
// const sum = numbers.reduce((acc, num) => acc + num, 0);
const handleInputChange = useCallback(e => {
setInputValue(e.target.value);
}, []);
const addNumber = useCallback(() => {
setNumbers(prevNumbers => [
...prevNumbers,
Math.floor(Math.random() * 10) + 1,
]);
}, []);
return (
<div>
<h1>Memoized Sum Component</h1>
<Input value={inputValue} onChange={handleInputChange} />
<Button onClick={addNumber} />
<Sum sum={sum} />
</div>
);
};
export default MemoizedSumComponent;
In useCallback we memozie callback function and in useMemo we memoized value returned by callback function.
Since the concepts are quite similar, I will refrain from providing an extensive explanation. I recommend reviewing the useCallback
explaination first, followed by this useMemo
polyfill for a clearer understanding.
As we have understood how useMemo
works, let's now proceed to implement the useMemoPolyfill
:
import { useRef } from 'react';
const useMemoPolyfill = (callback, dependencies) => {
const callbackRef = useRef({
value: undefined,
dependencies: undefined,
});
// Initially dependencies are undefined and value too so dependencies is considered as changed
if (dependencies && Array.isArray(dependencies)) {
const hasDependenciesChange = !deepCheck(
callbackRef.current.dependencies,
dependencies
);
if (hasDependenciesChange) {
if (typeof callback === 'function') {
callbackRef.current.value = callback();
} else {
throw new TypeError('Callback is not function');
}
callbackRef.current.dependencies = dependencies;
}
} else {
if (typeof callback === 'function') {
callbackRef.current.value = callback();
} else {
throw new TypeError('Callback is not function');
}
}
return callbackRef.current.value;
};
const deepCheck = (obj1, obj2) => {
return JSON.stringify(obj1) === JSON.stringify(obj2);
};
export default useMemoPolyfill;
Now, let's apply our useMemoPolyfill
to the same example and observe the results:
import React, { memo, useState, useMemo, useCallback } from 'react';
import useMemoPolyfill from './hooks/useMemoPolyfill';
const Input = memo(({ value, onChange }) => {
console.log('Input rendered');
return (
<input
type='text'
value={value}
onChange={onChange}
placeholder='Type something to re-render parent'
/>
);
});
const Button = memo(({ onClick }) => {
console.log('Button rendered');
return <button onClick={onClick}>Add Random Number</button>;
});
const Sum = memo(({ sum }) => {
console.log('Sum rendered');
return (
<div>
<h2>Sum of Numbers: {sum}</h2>
</div>
);
});
const MemoizedSumComponent = () => {
const [numbers, setNumbers] = useState([1, 2, 3, 4, 5]);
const [inputValue, setInputValue] = useState('');
const sum = useMemoPolyfill(() => {
console.log('Recalculating sum...');
return numbers.reduce((acc, num) => acc + num, 0);
}, [numbers]);
const handleInputChange = useCallback(e => {
setInputValue(e.target.value);
}, []);
const addNumber = useCallback(() => {
setNumbers(prevNumbers => [
...prevNumbers,
Math.floor(Math.random() * 10) + 1,
]);
}, []);
return (
<div>
<h1>Memoized Sum Component</h1>
<Input value={inputValue} onChange={handleInputChange} />
<Button onClick={addNumber} />
<Sum sum={sum} />
</div>
);
};
export default MemoizedSumComponent;
Polyfill of useDebounceHook
Let's begin by examining the functionality of useDebounce
through an example:
import React, { useState, useEffect, useRef } from 'react';
const Search = () => {
const [query, setQuery] = useState('');
const list = ['apple', 'banana', 'orange', 'grape', 'pear'];
const [filteredList, setFilteredList] = useState([]);
const delay = 1000;
const timeoutRef = useRef(null);
const memoizedSearch = useCallback(() => {
console.log("Callback Function triggered after delay - Filtering:", query);
let newFilteredList = list.filter(val => val.includes(query));
setFilteredList(newFilteredList);
}, [query]);
useEffect(() => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(memoizedSearch, delay);
return () => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
}
};
}, [memoizedSearch]);
return (
<>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{
filteredList.map((val, index) => <p key={index}>{val}</p>)
}
</>
);
};
export default Search;
The useDebounce hook is useful for delaying the execution of functions or state updates until a specified time period has passed without any further changes to the input value. This is especially useful in scenarios such as handling user input or triggering network requests, where it effectively reduces unnecessary computations and ensures that resource-intensive operations are only performed after a pause in the input activity.
What does it take?
callback
: A function to be executed after the debounce delay.delay
: A number (in milliseconds) that specifies how long to wait before calling thecallback
.
What does it return?
- Nothing: It’s a side-effect hook.
How does it perform?
It sets a
setTimeout
to call thecallback
after the specifieddelay
.If the component re-renders or the
callback
ordelay
changes, it clears the previoussetTimeout
and sets a new one, ensuring only the latest call is executed after thedelay
.It runs the
callback
function after thedelay
period once the user stops triggering changes (like typing in a search input).The
callback
in your case is memoized usinguseCallback
, which means it will only recreate when thequery
changes.
As we have understood how useDebounce
works, let's now proceed to implement the useDebounce
:
import { useRef, useEffect } from "react";
const useDebounce = (callback, delay) => {
const debounceRef = useRef({
timeout: null,
});
useEffect(() => {
debounceRef.current.timeout = setTimeout(callback, delay);
return () => {
if (debounceRef.current.timeout) {
clearTimeout(debounceRef.current.timeout);
}
};
}, [callback, delay]);
};
export default useDebounce;
Now, let's apply our useDebounce
to the same example and observe the results:
import React, { useState } from 'react';
import useDebounce from './hooks/useDebounce';
const Search = () => {
const [query, setQuery] = useState('');
const [filteredList, setFilteredList] = useState([]);
const list = ['apple', 'banana', 'orange', 'grape', 'pear'];
const memoizedSearch = useCallback(() => {
console.log("Callback Function triggered after delay - Filtering:", query);
let newFilteredList = list.filter(val => val.includes(query));
setFilteredList(newFilteredList);
}, [query]);
useDebounce(memoizedSearch, 1000);
return (
<>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{filteredList.map((val, index) => (
<p key={index}>{val}</p>
))}
</>
);
};
export default Search;
Polyfill of useThrottleHook
Let's begin by examining the functionality of useThrottle
through an example:
import React, { useState, useCallback, useEffect } from "react";
const Throttle = () => {
const [scrollPos, setScrollPos] = useState(0);
const [lastExecuted, setLastExecuted] = useState(0);
const delay = 500;
const handleScroll = useCallback(() => {
if (Date.now() - lastExecuted >= delay) {
setScrollPos(window.scrollY);
setLastExecuted(Date.now());
}
}, [lastExecuted]);
useEffect(() => {
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, [handleScroll]);
return (
<div style={{ height: "200vh" }}>
<p>Scroll Position : ${scrollPos}</p>
<p>Scroll the page, and you'll see the throttled position update.</p>
</div>
);
};
export default Throttle;
The useThrottle hook offers a controlled way to manage execution frequency in a React application. By accepting a value and an optional interval, it ensures that the value is updated at most every interval milliseconds. This is particularly helpful for limiting API calls, reducing UI updates, or mitigating performance issues by throttling computationally expensive operations.
What does it take?
callback
: The function to be throttled.delay
: Time interval (ms) between executions.
What does it return?
- A function that controls how often the original
callback
is executed.
How does it perform?
- It executes the
callback
at most once everydelay
milliseconds, either immediately or after waiting for the remaining time. It clears the timeout on every render to prevent multiple executions.
As we have understood how useThrottle
works, let's now proceed to implement the useThrottle
:
import { useRef, useCallback } from "react";
const useThrottle = (callback, delay) => {
const throttleRef = useRef({ lastExecuted: 0, timeout: null });
const throttledCallback = useCallback(() => {
const now = Date.now();
if (throttleRef.current.timeout) {
clearTimeout(throttleRef.current.timeout);
throttleRef.current.timeout = null;
}
if (now - throttleRef.current.lastExecuted >= delay) {
callback();
throttleRef.current.lastExecuted = now;
} else {
const remainingTime = delay - (now - throttleRef.current.lastExecuted);
throttleRef.current.timeout = setTimeout(() => {
callback();
throttleRef.current.lastExecuted = Date.now();
}, remainingTime);
}
}, [callback, delay]);
return throttledCallback;
};
export default useThrottle;
Now, let's apply our useThrottle
to the same example and observe the results:
import React, { useState, useEffect } from "react";
import useThrottle from './hooks/useThrottle';
const Throttle = () => {
const [scrollPos, setScrollPos] = useState(0);
const delay = 5000;
const handleScroll = useThrottle(() => {
console.log("Throttle callback executed");
setScrollPos(window.scrollY);
}, delay);
useEffect(() => {
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, [handleScroll]);
return (
<div style={{ height: "200vh" }}>
<p>Scroll Position : ${scrollPos}</p>
<p>Scroll the page, and you'll see the throttled position update.</p>
</div>
);
};
export default Throttle;
Mastering React's hooks is crucial for building performant applications, especially when dealing with states, side effects, function optimizations, and managing resource-intensive operations. By understanding and writing polyfill of hooks like useState
,useEffect
, useMemo
, useCallback
, useDebounce
, and useThrottle
, developers gain a deeper insight into React's internal workings. This knowledge enables more efficient applications and contributes to better code maintainability.