How I created a window manager using the React Context.
TL;DR
Introduction
I've been wanting to try and create some sort of desktop with a window manager in JavaScript for a while now.
I finally took the opportunity to add a frontend to a script that wasn't supposed to become a web application.
The backend doesn't matter for this article, but in this case, it's an Express API that provides data about retro games.
I didn't want to code a projects with tons of direct dependencies. I only added MUI to test it (side projects are the perfect purpose to test new tools 🤤).
The features
Desktop icons
The desktop icons can be moved and will always remain below the windows.
Double clicking on an icon will open a window to display its content or will move to the top an already opened window.
Window component
The window component will provide all classic features to mimic an OS window manager :
- draggable using the header
- resizable using mouse on reactive border
- resizable by double clicking on the header
- resizable by dedicated buttons in the header
- resizable when the browser window is resized
- displays some informations on footer
- updates footer depending on hovered icon
- focusable by using Tab
- closable by dedicated button in the header
- closable by using Escape
- contains file / game icons
- provides a search feature by using Ctrl + F or Command + F
Window icons
Like the desktop icons, the windows icons allows to open the game window.
The technical choices
The state management
I didn't wanted to use any Redux like state manager, I choosed to use the React context to manage the window stack.
I thought it would be a simple approach to handle the window z-indexes and their interactions (icons => window).
Contexts are powerful to provides "global" properties but they also can be used to provide state handler, and that's the approach I wanted to try.
The Window descriptor
Each window will get a unique id
, some properties and a state provided by... a dedicated window context.
interface IDescriptor {
id: string;
zIndex: number;
payload: WinPayload;
options: {
...
};
state: {
...
}
}
🤔 Why using a window context to manager those values and not a state?
🤡 Because I wanted to play with context
😎 Because it was a cool approach (I think) to avoid the prop drilling between the icons behaviour and the window / window manager.
Example:
- on hover, icons update the window footer
- global search activate the search on the active window
Basically, here is the window manager tree:
<WinManagerContext.Provider value={mainContext}>
{
descriptors.map(descriptor => (
<WinContext.Provider key={descriptor.id} value={winContext}>
{ render(descriptor.payload) }
</WinContext.Provider>
)
}
</WinManagerContext.Provider>
This is a simplified description of the tree because as you can imagine, there are several other concerns to consider.
👉 The icons available on the desktop needs to interact with the manager and are embedded in the provided context.
👉 There are several types of windows identified by a different payload type and rendered by a dedicated render function required by the window manager.
Coding
The React context and its usage
Of course, I'm not going to describe the whole code here, but I'm going to explain how the context is used and updated thanks to one simple feature.
Updating a window z-index
The goal here is to set the Megadrive window on the first plan when clicking on it (or when using Tab).
In code, it's setting its descriptor's z-index to the highest of the descriptor stack.
The window manager will provide for each window context a handler to focus on itself.
const WinManager: FC<Props> = ({ render, children }) => {
const [descriptors, setDescriptors] = useState<IDescriptor[]>([]);
const focus = (id: string) => {
setDescriptors(descriptors => {
const focused = descriptors.reduce((selected, descriptor) => selected.zIndex > descriptor.zIndex ? selected : descriptor);
return id === focused.id ? descriptors : descriptors.map(descriptor => descriptor.id === id ? {...descriptor, zIndex: focused.zIndex + 1} : descriptor);
});
}
return (
<WinManagerContext.Provider value={mainContext}>
{
descriptors.map(descriptor => (
<WinContext.Provider key={descriptor.id} value={{ focus: focus.bind(null, descriptor.id) }}>
{ render(descriptor.payload) }
</WinContext.Provider>
)
}
</WinManagerContext.Provider>
);
};
and the only thing to do in the window is to use this handler:
const Win = () => {
const { focus } = useContext(WinContext);
return (
<div onPointerDown={focus}>
...
</div>
);
}
🤟 To summarize, somewhere in the window's component tree, we can update the window manager state without having to deal with prop drilling, custom event or any other communication process.
Tips
🤓 Those pieces of code have been cleaned for ease of reading. In the real code, most of the functions are memoized for performance purposes (useCallback & useMemo).
That's one of the reasons the setDescriptor function is always used with a function as parameter (no need to use descriptors in the dependencies) and that's why it checks if the results really change to trigger a render or not (same array content checking).
Conclusion
Contextes are much more powerful than just providing theme or user data. Like every tools, it's not a silver bullet, use them when it's useful. 🙃
If you have any comments or questions, feel free to ask!
Top comments (0)