DEV Community

Neil Chen
Neil Chen

Posted on • Edited on

Make Your Global Components Elegant with EventTarget

What Are Global Components and EventTarget?

Global Components

Thanks to the component architecture, we can encapsulate state and logic into a small section. However, for some types of UI that exist in the global context and can be called from anywhere in the code, and it will be rendered as floating or overlay layout (such as Global Alert or Global Loading), we need a component that doesn't belong to a specific screen. I call these types of components Global Components.

Alert Example

Loading Example

EventTarget

EventTarget is a native browser API that is a separate constructor from the DOM's EventTarget interface. It can be a good browser implementation for EventEmitter, which already exists in Node.js's event module.

Want more detail? do check this Article for EventTarget

We can use EventTarget to listen and broadcast commands and information throughout the whole app without explicitly calling the impacted object. In other words, it makes global message passing easy.

class MyTarget extends EventTarget {}
const target = new MyTarget();
target.addEventListener(
    'sayHi',
    (event) => console.log(`sayHi to ${event.detail.name}`)
);
target.dispatchEvent(
    new CustomEvent('sayHi', { detail: { name: 'John' }  })
);
Enter fullscreen mode Exit fullscreen mode

Global Alert with EventTarget

To implement a Global Alert in React, we usually use two major options: Global State Management Libraries (like Redux or Recoil) or Context.

For the first choice, we need to add another library, which adds another layer of dependency for this component, making it harder to maintain and less portable.

For the second option, you can only access the command in the hooks or components with useContext, and since we usually let this type of component wrap the root of the UI tree, it will trigger lots of unnecessary re-renders for the whole app.

And the most important part: for Global Alert, we don't care about its state like shown or not, but only how to trigger it.

So is there a simple pattern to fulfill this requirement? Yes, the old but gold EventTarget is here for you.

Below is the example with MUI:

import { useState, useEffect } from 'react';
import { Snackbar, Alert, AlertProps } from '@mui/material';

interface AlertInfo {
  type: AlertProps['severity'];
  text: string;
}

class AlertEventTarget extends EventTarget {}
const alertEventTarget = new AlertEventTarget();

export const globalAlert = (alertInfo: AlertInfo) =>
  alertEventTarget.dispatchEvent(
    new CustomEvent('alert', { detail: { alertInfo } }),
  );

export const GlobalAlert = () => {
  const [isOpen, setIsOpen] = useState(false);

  const [alertInfo, setAlertInfo] = useState<AlertInfo>({
    type: 'success',
    text: 'success',
  });

  useEffect(() => {
    alertEventTarget.addEventListener('alert', (e) => {
      setIsOpen(true);
      setAlertInfo(
        (e as CustomEvent<{ alertInfo: AlertInfo }>).detail.alertInfo,
      );
    });
  }, []);

  return (
    <Snackbar
      open={isOpen}
      anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
      autoHideDuration={6000}
      onClose={() => setIsOpen(false)}
    >
      <Alert severity={alertInfo.type} sx={{ width: '100%', mt: 8 }}>
        {alertInfo.text}
      </Alert>
    </Snackbar>
  );
};

Enter fullscreen mode Exit fullscreen mode

We can simply put this Global Alert Component anywhere, ideally in App.tsx, and call it everywhere. Since we're not relying on the Context, we can even call it outside the hooks!

// App.tsx
import { GlobalAlert } from '@/components/GlobalAlert'
const App = () => {
  return (
    <>
      <AppContent />
      <GlobalAlert />
    </>
  );
};

// In the component/hook
import { globalAlert } from '@/components/GlobalAlert'
const ExampleComponent = () => {
  return (
    <>
      <button
        onClick={() =>
          globalAlert({ type: 'success', text: 'trigger alert successfully!' })
        }
      >
        Trigger Alert
      </button>
    </>
  );
};

// In the pure function outside the hooks
import { globalAlert } from '@/components/GlobalAlert'
const exampleRequest = async () => {
  try {
    axios.post('/api/v1/xxx');
  } catch (e) {
    globalAlert({ type: 'error', text: e.message });
  }
};

Enter fullscreen mode Exit fullscreen mode

With this pattern, we get the following benefits:

  • No unnecessary dependencies, making the component more portable
  • No need to use useContext or any other setup code to execute a little alert
  • Available calling alert outside the hook!

Global Loading Component Design

To explore more, we can extend this idea to Global Loading, like Overlay Loading or the Top Loading Bar, which are both quite common UI designs nowadays.

We can take a look at the final outcome at the beginning:

// App.tsx
import { GlobalOverlayLoading } from '@/components/GlobalOverlayLoading'
const App = () => {
  return (
    <>
      <AppContent />
      <GlobalOverlayLoading />
    </>
  );
};

// In normally components/hooks usage
import { useGlobalOverlayLoading } from '@/components/globalOverlayLoading';
const ExampleComponent = () => {
  const [isLoading, setIsLoading] = useState(false);
  useGlobalOverlayLoading(isLoading);
  return <>Content</>;
};

const ExampleHook = () => {
  const [isLoading, setIsLoading] = useState(false);
  useGlobalOverlayLoading(isLoading);
};
Enter fullscreen mode Exit fullscreen mode

As presented, we can see how easy it is to trigger the global overlay loading with just one hook with just one state prop! There's no need to import this component in every part of the tree. Simple and elegant.

To reach this result, we need more effort, but everything will be worth it in the long term!

First Version of Design

The biggest thing we need to deal with is: how to handle state changing from different parts of the component tree simultaneously? Because we only have one Global Loading component, but it will receive multiple states at the same time, and we need to stop the loading animation after all the loading is done.

For the first version, we can let the hook caller pass an identifying key to the hook:

const ExampleHook2 = () => {
  const [isLoading, setIsLoading] = useState(false);
  useGlobalOverlayLoading('ExampleHook2_Key', isLoading);
};
Enter fullscreen mode Exit fullscreen mode

This way, we can identify the different hooks or components to let them decide if the loading should stop. With this approach, we get the first version of the design:

import { Backdrop, CircularProgress } from '@mui/material';
import { useState, useEffect, useRef } from 'react';

class LoadingEventTarget extends EventTarget {}
const loadingEventTarget = new LoadingEventTarget();

interface LoadingEventDetail {
  key: string;
  isLoading: boolean;
}

export const useGlobalOverlayLoading = (key: string, state: boolean) => {
  useEffect(() => {
    loadingEventTarget.dispatchEvent(
      new CustomEvent<LoadingEventDetail>('setLoading', {
        detail: { key, isLoading: state },
      }),
    );

    return () => {
      loadingEventTarget.dispatchEvent(
        new CustomEvent<LoadingEventDetail>('setLoading', {
          detail: { key, isLoading: false },
        }),
      );
    };
  });
};

export const GlobalOverlayLoading = () => {
  const [isShow, setIsShow] = useState(false);

  const storeRef = useRef<{ [key: string]: boolean }>({});

  useEffect(() => {
    loadingEventTarget.addEventListener('setLoading', (e) => {
      const { key, isLoading } = (e as CustomEvent<LoadingEventDetail>).detail;
      let shouldShowLoading = false;
      storeRef.current[key] = isLoading;
      for (const prop in storeRef.current) {
        if (storeRef.current[prop]) {
          shouldShowLoading = true;
          break;
        }
      }
      setIsShow(shouldShowLoading);
    });
  }, []);

  return (
    <>
      {isShow && (
        <Backdrop
          sx={(theme) => ({
            zIndex: theme.zIndex.modal + 1,
          })}
          open
        >
          <CircularProgress sx={{ color: '#fff' }} />
        </Backdrop>
      )}
    </>
  );
};

Enter fullscreen mode Exit fullscreen mode

However, we wondered if we could make the process even simpler by just passing the loading state itself. To generate a unique ID for each hook or component, we introduced a cool React pattern called Hook Counter.

Hook Counter

let counter = 0;
const useHookCounter = () => {
  const [uniqKey] = useState(`key_${counter++}`);
};

Enter fullscreen mode Exit fullscreen mode

The code inside the useState hook is only executed when the hook is initialized, and the resulting state is preserved between each re-render, giving us a stable key to use. So, whenever a new component calls the useHookCounter hook, we can obtain a unique identifier to identify it.

Combine EventTarget & Hook Counter

By combining Hook Counter and EventTarget, we were able to achieve our desired outcome with minimal effort and higher flexibility and readability. The final result is presented below:

import { Backdrop, CircularProgress } from '@mui/material';
import { useState, useEffect, useRef } from 'react';

class LoadingEventTarget extends EventTarget {}
const loadingEventTarget = new LoadingEventTarget();

interface LoadingEventDetail {
  key: string;
  isLoading: boolean;
}

let counter = 0;
export const useGlobalOverlayLoading = (state: boolean) => {
  const [key] = useState(`key_${counter++}`);

  useEffect(() => {
    loadingEventTarget.dispatchEvent(
      new CustomEvent<LoadingEventDetail>('setLoading', {
        detail: { key, isLoading: state },
      }),
    );

    return () => {
      loadingEventTarget.dispatchEvent(
        new CustomEvent<LoadingEventDetail>('setLoading', {
          detail: { key, isLoading: false },
        }),
      );
    };
  });
};

export const GlobalOverlayLoading = () => {
  const [isShow, setIsShow] = useState(false);

  const storeRef = useRef<{ [key: string]: boolean }>({});

  useEffect(() => {
    loadingEventTarget.addEventListener('setLoading', (e) => {
      const { key, isLoading } = (e as CustomEvent<LoadingEventDetail>).detail;
      let shouldShowLoading = false;
      storeRef.current[key] = isLoading;
      for (const prop in storeRef.current) {
        if (storeRef.current[prop]) {
          shouldShowLoading = true;
          break;
        }
      }
      setIsShow(shouldShowLoading);
    });
  }, []);

  return (
    <>
      {isShow && (
        <Backdrop
          sx={(theme) => ({
            zIndex: theme.zIndex.modal + 1,
          })}
          open
        >
          <CircularProgress sx={{ color: '#fff' }} />
        </Backdrop>
      )}
    </>
  );
};

Enter fullscreen mode Exit fullscreen mode

Conclusion

In this article, we explored a way to design Global Components using the browser-native API, EventTarget, which allows for a global data passing workflow with minimized setup effort. For global state control that requires accessing and checking data, such as navigation or theme, we still recommend using Redux and Context. However, keep in mind that this will add another layer of complexity. We hope this pattern inspires you to design the architecture of your React application!

Top comments (0)