Mastering React Custom Hooks

ยท

5 min read

What are Custom Hooks?

Hooks were added in React 16.8 to separate and reuse component logic, including state and side effects.

Custom hooks are functions that allow you to reuse stateful logic between different React components. They are a way to extract component-specific logic into reusable functions, which can be shared between multiple components. Custom hooks typically start with the word "use" and allow you to use the state and other React features inside a functional component.

For example, you could create a custom hook useFormInput that manages the state of a form input field, and use that hook in multiple components to handle form input fields in a consistent way. This allows you to avoid duplicating logic and keeps your code organized and easy to maintain.

Create your custom hook

To create a custom hook you need to define a function which starts with the word "use" (E.g. useState, useFormInput). We will create useFormInput as an example.

The useFormInput hook will manage the state of our <form /> element.

/*
 * useFormInput hook to manage state of form
 * @param {object} defaultValue - takes default state of form elements
 */
const useFormInput = (defaultValue) => {
  const [data, setData] = useState(defaultValue || {});
  const setFormData = useCallback(
    (key, value) => {
      setData({ ...data, [key]: value });
    },
    [setData, data]
  );
  const memoizedValue = useMemo(
    () => ([
      data,
      setFormData,
    ]),
    [data, setFormData]
  );

  return memoizedValue;
};

export default useFormInput;

useFormInput hook usage in a top-level component.

// Top level App component 
const App = () => {
  // Usage of useFormInput hook
  const [formData, setFormData] = useFormInput({
    email: "", 
    password: ""
  });
  const submitForm = (e) => {
    e.preventDefault();
    console.log("form submitted");
    console.log("submitted data:", formData);
  }

  return (
    <>
      <form onSubmit={submitForm}>
        <input
          id="email"
          name="email"
          defaultValue={formData.email}
          onChange={e => {
            setFormData("email", e.target.value);
          }}
        />
        <input
          id="password"
          name="password"
          defaultValue={formData.password}
          onChange={e => {
            setFormData("password", e.target.value);
          }}
        />
        <button type="submit">Login</button>
      </form>
    </>
  );
};

export default App;

Now that we know how custom hooks are created, let's take a look at some useful custom hooks.

Useful Hooks

useBreakpoint

useBreakpoint hook returns a boolean value for a specific predefined breakpoint. This can be used to apply classes or styles responsively.

The hook also takes an optional target element and custom breakpoints to observe element resize. By default, it observes <body /> element of the HTML document.

const defaultBreakpoints: Record<string, number> = {
  base: 0,
  xs: 480,
  sm: 640,
  md: 768,
  lg: 1024,
  xl: 1280,
  "2xl": 1536,
};

const useBreakpoint = (
  bp: string,
  target?: Element,
  breakpoints: Record<string, number> = defaultBreakpoints
) => {
  const [state, setState] = useState(false);
  const [loaded, setLoaded] = useState(false);

  useEffect(() => {
    if (Object.keys(breakpoints).indexOf(bp) < 0)
      throw new Error("Unknown breakpoint");

    const observer = new ResizeObserver(([entry]) => {
      const wd = entry.contentRect.width;
      setState(wd >= breakpoints[bp]);
    });
    observer.observe(target || document.body);
    setLoaded(true);

    return () => observer.unobserve(target || document.body);
  }, [bp, target, breakpoints, setState]);

  return !loaded ? undefined : state;
};

export default useBreakpoint;

// Example usage - const sm = useBreakpoint("sm");

useScroll

useScroll hook returns vertical and horizontal scroll offset. The hook can optionally take a target element. This can be used to target the scroll of a specific element. The hook by default targets the window scroll event.

const useScroll = (ele?: Element) => {
  const [offset, setOffset] = useState({x: 0, y: 0});

  useEffect(() => {
    function handleScroll() {
      const [x, y] = ele
        ? [ele.scrollTop, ele.scrollLeft]
        : [window.scrollX, window.scrollY];
      setOffset({ x, y });
    }
    window.addEventListener("scroll", handleScroll, {passive: true});
    return () => window.removeEventListener("scroll", handleScroll);
  }, [ele]);

  return offset;
};

export default useScroll;

// Example usage - const {x, y} = useScroll()

useToast

useToast hook uses the Provider pattern and can only be used inside the Provider boundary. In our case, we will be using Context API to implement a ToastBoundary inside which, the hook can be used.

The top-level component must be wrapped in ToastBoundary before we use useToast hook. The hook takes an object with id , content and timeout of the toast.

type ToastContextType = {
  show: boolean;
  setShow: (v: boolean) => void;
  setToast: (v: {
    id: string | null;
    content: string | ReactNode | null;
  }) => void;
}

type ToastType = {
    id: string | null;
    content: string | ReactNode | null;
}

const ToastContext = createContext<ToastContextType>({
  show: false,
  setShow: (v) => {},
  setToast: (v) => {},
});

export const ToastBoundary: FC<{ children: ReactNode }> = ({ children }) => {
  const [show, setShow] = useState<boolean>(false);
  const [toast, setToast] = useState<ToastType>({ 
    id: null, 
    content: null 
  });
  const memoizedVals = useMemo(
    () => ({ show, setShow, setToast }),
    [show]
  );

  return (
    <ToastContext.Provider value={memoizedVals}>
      {children}
      <div id={toast.id ? toast.id + "_container" : undefined} className="toast-container">
        <div id={toast.id ?? undefined} className="toast">
          {toast.content}
        </div>
      </div>
    </ToastContext.Provider>
  );
};

const useToast = ({
  id,
  content,
  timeout = 2000,
}: {
  id: string | null;
  content: string | ReactNode | null;
  timeout?: number;
}) => {
  const { show, setToast, setShow } = useContext(ToastContext);
  const memoizedState = useMemo(
    () => ({
      show: () => setShow(true),
      hide: () => setShow(false),
    }),
    [setShow]
  );

  useEffect(() => {
    const timer = setTimeout(() => {
      if (show) setShow(false);
    }, timeout);
    return () => clearTimeout(timer);
  }, [show, setShow, timeout]);

  useEffect(() => {
    setToast({ id, content });
  }, [setToast, id, content]);

  return memoizedState;
};

export default useToast;

/* 
Example usage -

~ In Top level component
...
return(
  <ToastBoundary>
    <App />
  </ToastBoundary>
);

~ In component file
const toast = useToast({ 
  id: "example_toast", 
  content: "Example Toast Content"
});
*/

Conclusion

Hopefully, by now you have a better understanding of React custom hooks and the hooks that are given in this article might come in handy in your development journey.

Comment in case you need a part 2 of this.

Cheers ๐ŸŽ‰๐ŸŽ‰

ย