Typed server-safe DOM event listeners in Remix

Photo by Jase Bloor on Unsplash

Typed server-safe DOM event listeners in Remix

Have you ever written a component that needs to interact with the DOM? For example, you write a tooltip component that needs to listen to the window scroll event to re-position itself in the browser. Maybe a component that listens to the width of the window and adjusts its width according to that information? Well, this is the article for you!

I'll go over how to implement a custom hook that attaches to any DOM node and listens to events and then some utilities that you can use to attach to some specific, more popular ones like window and document.

These examples will work both on the server and on the client which makes them perfectly safe to use in any framework or even React on its own.

DOM listener hook

Let's first go over the hook, you might have seen this implementation before and it doesn't get much better than this Typescript variant that fully types every event you use:

export const useEventListener = <
  E extends Events[keyof Events],
  Type extends keyof Pick<Events, { [K in keyof Events]: Events[K] extends E ? K : never }[keyof Events]>
>(
  element: ListenerElements | undefined,
  type: Type,
  handler: (event: Events[Type]) => void,
  options?: AddEventListenerOptions | boolean
) => {
  const savedHandler = useRef(handler);

  useEffect(() => {
    savedHandler.current = handler;
  }, [handler]);

  useEffect(() => {
    const listener: EventListenerOrEventListenerObject = event => savedHandler.current(event as never);

    element?.addEventListener(type, listener, options);

    return () => {
      element?.removeEventListener(type, listener, options);
    };
  }, [type, element, options]);
};

Let's break down the code step by step:

  1. Type Parameters:

    • E represents the type of the event you want to listen to. It is constrained to be one of the event types defined in the Events object.

    • Type represents the specific type of the event, which is a key of the Events object. The constraint ensures that Type is a valid event type.

<E extends Events[keyof Events], Type extends keyof Pick<Events, { [K in keyof Events]: Events[K] extends E ? K : never }[keyof Events]>>
  1. Function Parameters:

    • element is the DOM element to which the event listener will be attached.

    • type is the specific type of event you want to listen to.

    • handler is the callback function that will be invoked when the specified event occurs.

    • options is an optional parameter representing the options for the event listener.

(element: ListenerElements | undefined, type: Type, handler: (event: Events[Type]) => void, options?: AddEventListenerOptions | boolean)
  1. State Management with useRef:

    • savedHandler is a useRef that holds the latest version of the event handler. It is used to ensure that the most recent version of the handler is used inside the event listener attachment useEffect.
const savedHandler = useRef(handler);

useEffect(() => {
  savedHandler.current = handler;
}, [handler]);
  1. Effect for Adding and Removing Event Listeners:

    • The first useEffect is responsible for setting up the event listener when the component mounts.

    • It defines a function listener that calls the saved handler with the event as an argument.

    • The element?.addEventListener method is used to attach the event listener to the specified element.

    • The return statement in the first useEffect defines a cleanup function that removes the event listener when the component unmounts.

useEffect(() => {
  const listener: EventListenerOrEventListenerObject = event => savedHandler.current(event as never);

  element?.addEventListener(type, listener, options);

  return () => {
    element?.removeEventListener(type, listener, options);
  };
}, [type, element, options]);

In summary, useEventListener is designed to simplify the process of adding and removing event listeners on DOM elements. The typed constraints allow us to get fully typed events and useRef allows us to mount the listener only once.

Utilities

Now that we have gone over the basic implementation let's implement three utilities to make our lives easier, one that attaches events to the window, one to the document, and one to the body.

Window listener

export const useWindowListener = <E extends keyof WindowEventMap>(
  type: E,
  handler: (event: WindowEventMap[E]) => void,
  options?: boolean | AddEventListenerOptions
) => useEventListener(typeof window !== "undefined" ? window : undefined, type, handler, options);

Document listener

export const useDocumentListener = <E extends keyof DocumentEventMap>(
  type: E,
  handler: (event: DocumentEventMap[E]) => void,
  options?: boolean | AddEventListenerOptions
) => useEventListener(typeof document !== "undefined" ? document.body : undefined, type, handler, options);

Body listener

export const useBodyListener = <E extends keyof DocumentEventMap>(
  type: E,
  handler: (event: DocumentEventMap[E]) => void,
  options?: boolean | AddEventListenerOptions
) => useEventListener(typeof document !== "undefined" ? document.body : undefined, type, handler, options);

You will notice that the code is almost identical across all three utilities. We just narrow the typing to be specific to what we're looking for and pass in the correct target.

You might be wondering why we do the typeof window !== "undefined" and typeof document !== "undefined" in our code above.

The reason behind this is that Remix renders everything on the server as well. The server does not have the document property and it would crash before even reaching your client so we guard against it. It is only attached when the code is in the client. The same reasoning applies to the window object.

Usage

Well, let's try these bad boys out! Add them to any of your components:

export const Component = () => {
  useWindowListener("resize", e => {
    // Fully typed and server safe
    console.log("resize", e);
  });

  useDocumentListener("mousedown", e => {
    // Fully typed and server safe
    console.log("mousedown", e);
  });

  useBodyListener("mouseout", e => {
    // Fully typed and server safe
    console.log("mouseout", e);
  });

  return <div />
}

The event object you get back is fully type-safe and they don't break your app when they are server-rendered! Awesome, and if you want to add them to a specific element you can always use the base hook like so:

export const Component = () => {
  const ref = useRef();

  useEventListener(ref.current, "click", e => {
    // Fully typed event and server safe!
  })

  return <div ref={ref} />
}

Full code

Did you come here to copy-paste? Here you go:

type Events = HTMLElementEventMap & WindowEventMap & DocumentEventMap & MediaQueryListEventMap;

type ListenerElements = Document | HTMLElement | MediaQueryList | Window;

export const useEventListener = <
  E extends Events[keyof Events],
  Type extends keyof Pick<Events, { [K in keyof Events]: Events[K] extends E ? K : never }[keyof Events]>
>(
  element: ListenerElements | undefined,
  type: Type,
  handler: (event: Events[Type]) => void,
  options?: AddEventListenerOptions | boolean
) => {
  const savedHandler = useRef(handler);

  useEffect(() => {
    savedHandler.current = handler;
  }, [handler]);

  useEffect(() => {
    const listener: EventListenerOrEventListenerObject = event => savedHandler.current(event as never);
    element?.addEventListener(type, listener, options);
    return () => {
      element?.removeEventListener(type, listener, options);
    };
  }, [type, element, options]);
}; 

export const useWindowListener = <E extends keyof WindowEventMap>(
  type: E,
  handler: (event: WindowEventMap[E]) => void,
  options?: boolean | AddEventListenerOptions
) => useEventListener(typeof window !== "undefined" ? window : undefined, type, handler, options);

export const useDocumentListener = <E extends keyof DocumentEventMap>(
  type: E,
  handler: (event: DocumentEventMap[E]) => void,
  options?: boolean | AddEventListenerOptions
) => useEventListener(typeof document !== "undefined" ? document.body : undefined, type, handler, options);

export const useBodyListener = <E extends keyof DocumentEventMap>(
  type: E,
  handler: (event: DocumentEventMap[E]) => void,
  options?: boolean | AddEventListenerOptions
) => useEventListener(typeof document !== "undefined" ? document.body : undefined, type, handler, options);

Thank you

If you've reached the end you're a champ! I hope you liked my article.

If you liked the article consider sharing it with others, or tagging me on Twitter with your thoughts, would love to hear them!

If you wish to support me follow me on Twitter here:

twitter.com/AlemTuzlak59192

or if you want to follow my work you can do so on GitHub:

github.com/AlemTuzlak

And you can also sign up for the newsletter to get notified whenever I publish something new!