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.
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' } })
);
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>
);
};
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 });
}
};
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);
};
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);
};
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>
)}
</>
);
};
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++}`);
};
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>
)}
</>
);
};
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)