Polyfilling React's Built-in Hooks & Custom Hooks

Polyfilling React's Built-in Hooks & Custom Hooks

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:

  1. No Dependencies (useEffect(callback)):

    • The effect runs on every render, which may lead to unnecessary executions and inefficiency.
  2. Empty Dependency Array (useEffect(callback, [])):

    • The effect runs only once on the initial render and never again, providing stability throughout the component's lifecycle.
  3. 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:

  1. No Dependencies (useCallback(callback)):

    • The callback is recreated on every render, making useCallback ineffective.
  2. Empty Dependency Array (useCallback(callback, [])):

    • The callback is created only once on the first render and remains stable throughout the component lifecycle.
  3. 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 the callback.

What does it return?

  • Nothing: It’s a side-effect hook.

How does it perform?

  • It sets a setTimeout to call the callback after the specified delay.

  • If the component re-renders or the callback or delay changes, it clears the previous setTimeout and sets a new one, ensuring only the latest call is executed after the delay.

  • It runs the callback function after the delay period once the user stops triggering changes (like typing in a search input).

  • The callback in your case is memoized using useCallback, which means it will only recreate when the query 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 every delay 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.