React better useEffect with mount status

React better useEffect with mount status

React hook useStatusEffect

Something that always comes up in my react applications is the need to useEffect but skip the initial render, and skip setting state if unmounting. You can search and read literally hundreds of posts about this, and there seems to be some divide about weather it is good or bad practice to do this. Regardless at some point in a react application using functional components and hooks, you will probably run into a scenario where one or both of these strategies are needed.

The typical way to handle this is to useRef to store the mount and/or unmount status (can get unmount status by returning a destructure function in useEffect). The boilerplate code would look as follows:

// Search when user types and criteria changes
const updated = useRef(false)

useEffect(() => {

  If (!updated.current) {
    updated.current = true
    return
  }

  // Get search data from api when criteria changes
  search(criteria)
    .then((searchResults) => {
      if (updated.current) {
        setSearchResults(searchResults)
      }
    }).catch((searchError) => {
      if (updated.current) {
        setSearhError(searchError)
      }
    })

  // Make sure we do not set state when unmounted by returning a destructure fn which sets our status
  return () => {
    updated.current = false
  }
}, [criteria])

In this example, searchResults could return an array of data when criteria changes, and you would not want to run this until the component is updated after mounted, as the user would not be searching until the component is mounted. Pretty basic, but you get the point. As you keep creating more components you'll probably have to do this again and again, so let's repeat less code, even if we are saving just a few lines of code (the useRef and destructure). Here's our new hook:

// status-effect.hook.ts
import { useRef, useEffect, DependencyList, MutableRefObject } from "react";

type LifecycleStatus = "mount" | "update" | "unmount";

type StatusEffectCallback = (status: MutableRefObject<LifecycleStatus>) => void | (() => void);

/**
 * Accepts a function that contains imperative, possibly effectful code.
 * Passes the current mount status of `mount`, `update`, or `unmount` as a ref which
 * Can then be checked for the real time status, including `unmount`.
 *
 * @param effect Imperative function that can return a cleanup function.
 * @param deps If present, effect will only activate if the values in the list change.
 *
 * @returns the lifecycle status which can be used outside of this hook.
 */
export function useStatusEffect(effect?: StatusEffectCallback, deps?: DependencyList) {
  const status = useRef<LifecycleStatus>("mount");
  const mounted = useRef(false);

  // Update status.
  useEffect(() => {
    // Skip the first render (mount).
    if (!mounted.current) {
      mounted.current = true;
      return;
    }

    status.current = "update";

    return () => {
      status.current = "unmount";
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);

  // Call the callback.
  useEffect(() => {
    if (!effect) {
      return;
    }

    return effect(status);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);

  return { status };
}

Okay pretty straight forward, now we have something we can call in place of useEffect which will pass the current status of the component, for you to evaluate inside (or outside if needed) of useStatusEffect. Let's replace the useEffect example with this.

// Search when user types and criteria changes
useStatusEffect((status) => {
  // Skip first render as we only want to eval this effect when input changes actually happen.
  if (status.current === "mount") {
    return;
  }

  // Get search data from api when criteria changes
  search(criteria).then((searchResults) => {
    if (status.current !== "unmount") {
      setSearchResults(searchResults)
    }
  }).catch((searchError) => {
    if (status.current !== "unmount") {
      setSearhError(searchError)
    }
  })

  // Destructure not needed as status contains the real time status and is set when destructured in the hook.
}, [criteria])

Enjoy!