paint-brush
Master React by Designing Effective APIs With the useImperativeHandle Hookby@socialdiscoverygroup
152 reads

Master React by Designing Effective APIs With the useImperativeHandle Hook

by Social Discovery GroupDecember 23rd, 2024
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

The useImperativeHandle hook in React allows developers to customize the methods and properties exposed by a component, enhancing flexibility and maintainability. It works with forwardRef to provide a programmatic interface for child components, enabling direct control over their behavior. Best practices include isolating child logic, simplifying integration with third-party libraries, and avoiding common pitfalls like incorrect dependency arrays. By using this hook effectively, developers can create more efficient components and improve overall app performance.
featured image - Master React by Designing Effective APIs With the useImperativeHandle Hook
Social Discovery Group HackerNoon profile picture

In modern React development, the useImperativeHandle hook is a powerful way to personalize a component's exposed value and gives more control over its internal methods and properties. As a result, more efficient Component APIs can improve the product's flexibility and maintainability.


In this piece, the Social Discovery Group team´s insights dive into the best practices for using useImperativeHandle effectively to enhance React components.


React provides many hooks (the official documentation describes 17 hooks as of this writing) for managing state, effects, and interactions between components.


Among them, useImperativeHandle is a useful tool for creating a programmatic interface (API) for child components, which was added to React from version 16.8.0 onward.


useImperativeHandle allows you to customize what will be returned by the ref passed to a component. It works in tandem with forwardRef, which allows a ref to be passed to a child component.


useImperativeHandle(ref, createHandle, [deps]);
  • ref — the reference passed to the component.
  • createHandle — a function returning an object that will become accessible via the ref.
  • deps — the dependency array.


This hook allows external control of a component's behavior, which can be useful in certain situations, such as working with third-party libraries, complex animations, or components that require direct access to methods. However, it should be used cautiously, as it breaks React's declarative approach.

Basic Example


Let’s imagine we need to manipulate the DOM of a child component. Here's an example of how to do this using a ref.

import React, { forwardRef, useRef } from 'react';

const CustomInput = forwardRef((props, ref) => {
  // Use forwardRef to pass the ref to the input element
  return <input ref={ref} {...props} />;
});

export default function App() {
  const inputRef = useRef();

  const handleFocus = () => {
    inputRef.current.focus(); // Directly controlling the input
  };

  const handleClear = () => {
    inputRef.current.value = ''; // Directly controlling the input value
  };

  return (
    <div>
      <CustomInput ref={inputRef} />
      <button onClick={handleFocus}>Focus</button>
      <button onClick={handleClear}>Clear</button>
    </div>
  );
}

And here’s how it can be achieved using useImperativeHandle.

import React, { useImperativeHandle, forwardRef, useRef } from 'react';

const CustomInput = forwardRef((props, ref) => {
  const inputRef = useRef();

  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    },
    clear: () => {
      inputRef.current.value = '';
    },
  }));

  return <input ref={inputRef} {...props} />;
});

export default function App() {
  const inputRef = useRef();

  return (
    <div>
      <CustomInput ref={inputRef} />
      <button onClick={inputRef.current.focus}>Focus</button>
      <button onClick={inputRef.current.clear}>Clear</button>
    </div>
  );
}


As seen in the examples above, when using useImperativeHandle, the child component provides the parent with a set of methods that we define ourselves.


Advantages of useImperativeHandle compared to simply using ref


  1. Isolates child component logic: Allows providing parent components only with the required methods.
  2. Simplifies integration: Makes it easier to integrate React components with libraries requiring direct DOM access, such as Lottie or Three.js.

Advanced Scenarios

Using useImperativeHandle in advanced scenarios, such as in examples with animation, allows isolating complex behavior inside a component. This makes the parent component simpler and more readable, especially when working with animation or sound libraries.



import React, { useRef, useState, useImperativeHandle, forwardRef, memo } from "react";
import { Player } from '@lottiefiles/react-lottie-player'
import animation from "./animation.json";

const AnimationWithSound = memo(
    forwardRef((props, ref) => {
        const [isAnimating, setIsAnimating] = useState(false);
        const animationRef = useRef(null);
        const targetDivRef = useRef(null);

        useImperativeHandle(
            ref,
            () => ({
                startAnimation: () => {
                    setIsAnimating(true);
                    animationRef.current?.play()
                    updateStyles("start");
                },
                stopAnimation: () => {
                    animationRef.current?.stop()
                    updateStyles("stop");
                },
            }),
            []
        );

        const updateStyles = (action) => {
            if (typeof window === 'undefined' || !targetDivRef.current) return;
            if (action === "start") {
                if (targetDivRef.current.classList.contains(styles.stop)) {
                    targetDivRef.current.classList.remove(styles.stop);
                }

                targetDivRef.current.classList.add(styles.start);
            } else if (action === "stop") {
                if (targetDivRef.current.classList.contains(styles.start)) {
                    targetDivRef.current.classList.remove(styles.start);
                }

                targetDivRef.current.classList.add(styles.stop);
            }
        };

        return (
            <div>
                <Player
                    src={animation}
                    loop={isAnimating}
                    autoplay={false}
                    style={{width: 200, height: 200}}
                    ref={animationRef}
                />
                <div ref={targetDivRef} className="target-div">
                    This div changes styles
                </div>
            </div>
        );
    })
);

export default function App() {
    const animationRef = useRef();

    const handleStart = () => {
        animationRef.current.startAnimation();
    };

    const handleStop = () => {
        animationRef.current.stopAnimation();
    };

    return (
        <div>
            <h1>Lottie Animation with Sound</h1>
            <AnimationWithSound ref={animationRef} />
            <button onClick={handleStart}>Start Animation</button>
            <button onClick={handleStop}>Stop Animation</button>
        </div>
    );
}


In this example, the child component returns the methods startAnimation and stopAnimation, which encapsulate complex logic within themselves.


Common Errors and Pitfalls

1. Incorrectly filled dependency array

The error is not always noticeable immediately. For example, the parent component might frequently change props, and you might encounter a situation where an outdated method (with stale data) continues to be used.


Example error:

https://use-imperative-handle.levkovich.dev/deps-not-correct/wrong

const [count, setCount] = useState(0);

const increment = useCallback(() => {
    console.log("Current count in increment:", count); // Shows old value
    setCount(count + 1); // Are using the old value of count
}, [count]);

useImperativeHandle(
    ref,
    () => ({
        increment, // Link to the old function is used
    }),
    [] // Array of dependencies do not include increment function
);


The right approach:

https://use-imperative-handle.levkovich.dev/deps-not-correct/correct

const [count, setCount] = useState(0);

useImperativeHandle(
    ref,
    () => ({
        increment,
    }),
    [increment] // Array of dependencies include increment function
);


2. Missing dependency array


If the dependency array is not provided, React will assume that the object in useImperativeHandle should be recreated on every render. This can cause significant performance issues, especially if "heavy" computations are performed within the hook.


Example error:

useImperativeHandle(ref, () => {
	// There is might be a difficult task
    console.log("useImperativeHandle calculated again");
 
    return {
        focus: () => {}
    }
}); // Array of dependencies is missing


The right approach:

https://use-imperative-handle.levkovich.dev/deps-empty/correct

useImperativeHandle(ref, () => {
	// There is might be a difficult task
    console.log("useImperativeHandle calculated again");
 
    return {
        focus: () => {}
    }
}, []); // Array of dependencies is correct


  1. Modifying the ref inside useImperativeHandle

Direct modification of ref.current disrupts React's behavior. If React attempts to update the ref, it can lead to conflicts or unexpected errors.


Example error:

https://use-imperative-handle.levkovich.dev/ref-modification/wrong


useImperativeHandle(ref, () => {
    // ref is mutated directly
    ref.current = { customMethod: () => console.log("Error") };
});

The right approach:

https://use-imperative-handle.levkovich.dev/ref-modification/correct


useImperativeHandle(ref, () => ({
    customMethod: () => console.log("Correct"),
}));


  1. Using methods before they are initialized


Calling methods provided via useImperativeHandle from useEffect or event handlers, assuming the ref is already available, can lead to errors — always check current before calling its methods.


Example error:


https://use-imperative-handle.levkovich.dev/before-init/wrong

const increment = useCallback(() => {
    childRef.current.increment();
}, [])


The right approach:

https://use-imperative-handle.levkovich.dev/before-init/correct


const increment = useCallback(() => {
    if (childRef.current?.increment) {
        childRef.current.increment()
    }
}, [])


  1. Synchronization issues between animations and state

If useImperativeHandle returns methods that synchronously change state (e.g., starting an animation and simultaneously modifying styles), it may cause a "gap" between the visual state and internal logic. Ensure consistency between state and visual behavior, for example, by using effects (useEffect).


Example error:

https://use-imperative-handle.levkovich.dev/state-animation-sync/wrong


useImperativeHandle(ref, () => ({
    startAnimation: () => {
        setState("running");
        // Animation starts before the state changes
        lottieRef.current.play();
    },
    stopAnimation: () => {
        setState("stopped");
        // Animation stops before the state changes
        lottieRef.current.stop();
    },
}));


The right approach:


https://use-imperative-handle.levkovich.dev/state-animation-sync/correct

useEffect(() => {
    if (state === "running" && lottieRef.current) {
        lottieRef.current.play();
    } else if (state === "stopped" && lottieRef.current) {
        lottieRef.current.stop();
    }
}, [state]); // Triggered when the state changes

useImperativeHandle(
    ref,
    () => ({
        startAnimation: () => {
            setState("running");
        },
        stopAnimation: () => {
            setState("stopped");
        },
    }),
    []
);

Conclusion

Using useImperativeHandle is justified in the following situations:


  • Controlling child component behavior: For example, to provide a focus or reset method for a complex input component.

  • Hiding implementation details: The parent component only receives the methods it needs, not the entire ref object.


Before using useImperativeHandle, ask yourself these questions:

  • Can the task be solved declaratively using state, props, or context? If yes, that is the preferred approach.
  • If not, and the component needs to provide an external interface, use useImperativeHandle.


By mastering the useImperativeHandle hook, React developers can create more efficient and maintainable components by selectively exposing methods. The techniques laid out by theSocial Discovery Group team can help developers improve their flexibility, streamline their component APIs, and enhance overall app performance.


Written by Sergey Levkovich, Senior Software Engineer at Social Discovery Group