Build an extendable in-browser devtools

Prepare the project

npm i @chakra-ui/react @emotion/react@^11 @emotion/styled@^11 framer-motion@^6 
import { ChakraProvider } from '@chakra-ui/react';

function MyApp({ Component, pageProps }) {
return (
<ChakraProvider>
<Component {...pageProps} />
</ChakraProvider>
);
}

export default MyApp;

Create a couple of pages

import Head from 'next/head';
import NextLink from 'next/link';
import styles from '../styles/Home.module.css';

const HomePage = () => {
return (
<div className={styles.container}>
<Head>
<title>Index Page</title>
<meta name="description" content="Index Page" />
<link rel="icon" href="/favicon.ico" />
</Head>

<main className={styles.main}>
<h1 className={styles.title}>Index Page</h1>

<NextLink href="/layout" passHref>
Go to layout page
</NextLink>
</main>
</div>
);
};

export default HomePage;
import Head from 'next/head';
import NextLink from 'next/link';
import styles from '../styles/Home.module.css';

const LayoutPage = () => {
return (
<div className={styles.container}>
<Head>
<title>Layout Page</title>
<meta name="description" content="Layout Page" />
<link rel="icon" href="/favicon.ico" />
</Head>

<main className={styles.main}>
<h1 className={styles.title}>Layout Page</h1>
<NextLink href="/">Go to index page</NextLink>
</main>
</div>
);
};

export default LayoutPage;

Create the devtools

import {
Box,
Button,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
} from '@chakra-ui/react';
import { useState } from 'react';

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

if (!isOpen) {
return (
<Box bottom="0" left="0" padding="1rem" position="fixed" zIndex="100000">
<Button onClick={() => setIsOpen(true)}>Show</Button>
</Box>
);
}

return (
<Box
backgroundColor="white"
bottom="0"
left="0"
padding="1rem"
position="fixed"
right="0"
zIndex="100000"
>
<Tabs isLazy variant="enclosed">
<TabList>
<Tab>One</Tab>
<Tab>Two</Tab>
<Tab>Three</Tab>
</TabList>

<TabPanels maxHeight="300px" overflowX="auto">
<TabPanel>
<p>one!</p>
</TabPanel>
<TabPanel>
<p>two!</p>
</TabPanel>
<TabPanel>
<p>three!</p>
</TabPanel>
</TabPanels>
</Tabs>

<Button onClick={() => setIsOpen(false)}>Hide</Button>
</Box>
);
};

export default Devtools;
import { ChakraProvider } from '@chakra-ui/react';
import dynamic from 'next/dynamic';

const Devtools = dynamic(() => import('../components/Devtools/Devtools'), {
ssr: false,
});

function MyApp({ Component, pageProps }) {
return (
<ChakraProvider>
<Component {...pageProps} />
<Devtools />
</ChakraProvider>
);
}

export default MyApp;

Make the tabs dynamic

import { useEffect, useState } from 'react';

// Extend the built-in Map to add some custom behaviour
class CustomMap extends Map {
// Add a placeholder property to hold a callback function.
// We'll override it later in our custom hook
callbackFn = (updatedMap) => { /* TODO */};

// Override the delete method to call the callbackFn
// with the updated Map after deleting a key
delete(key) {
const result = super.delete(key);
// Pass `this` to callbackFn
// to give access to the updated values
this.callbackFn(this);
return result;
}

// Override the set method to call the callbackFn
// with the updated Map after setting a new key
set(key, value) {
super.set(key, value);
// Pass `this` to callbackFn
// to give access to the updated values
this.callbackFn(this);
return this;
}
}

// Initialize a CustomMap in a module level
const tabsMap = new CustomMap();
// Create a helper function to convert the CustomMap into an array
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/entries
const getTabs = () => Array.from(tabsMap.entries());

// Export a custom hook to expose the tabs array
export const useDynamicTabs = () => {
const [tabs, setTabs] = useState(getTabs);

useEffect(() => {
// And subscribe so that any change to the map causes a re-render
tabsMap.callbackFn = () => setTabs(getTabs);
}, []);

return tabs;
};

// Export a function to register a new tab
// which returns an "unsubscribe" function for that tab
export const registerTab = (key, value) => {
tabsMap.set(key, value);

return () => {
tabsMap.delete(key);
};
};
import {
Box,
Button,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
} from '@chakra-ui/react';
import { useState } from 'react';
import { useDynamicTabs } from './tabs';

const Devtools = () => {
const [isOpen, setIsOpen] = useState(false);
const dynamicTabs = useDynamicTabs();

if (!isOpen) {
return (
<Box bottom="0" left="0" padding="1rem" position="fixed" zIndex="100000">
<Button onClick={() => setIsOpen(true)}>Show</Button>
</Box>
);
}

return (
<Box
backgroundColor="white"
bottom="0"
left="0"
padding="1rem"
position="fixed"
right="0"
zIndex="100000"
>
<Tabs isLazy variant="enclosed">
<TabList>
{dynamicTabs.map(([name]) => (
<Tab key={name}>{name}</Tab>
))}
</TabList>

<TabPanels maxHeight="300px" overflowX="auto">
{dynamicTabs.map(([name, content]) => (
<TabPanel key={name}>{content}</TabPanel>
))}
</TabPanels>
</Tabs>

<Button onClick={() => setIsOpen(false)}>Hide</Button>
</Box>
);
};

export default Devtools;
// other imports
import { registerTab } from '../components/Devtools/tabs';

registerTab('Tab #1', 'Our first tab');
registerTab('Tab #2', 'Our second tab');

const Devtools = dynamic(() => import('../components/Devtools/Devtools'), {
ssr: false,
});
// rest of the code

Contextual tabs

// other imports
import { useEffect } from 'react';
import { registerTab } from '../components/Devtools/tabs';

const HomePage = () => {
useEffect(() => registerTab('Index', 'Devtools on the index page'), []);

// rest of the code
};

export default HomePage;
// other imports
import { useEffect } from 'react';
import { registerTab } from '../components/Devtools/tabs';

const LayoutPage = () => {
useEffect(() => registerTab('Layout', 'Devtools on the layout page'), []);

// rest of the code
};

export default LayoutPage;

Relevant notes

Closing thoughts

  • Render the devtools based on a specific cookie value
  • Allow the user to resize the devtools with an auto-hide feature
  • Persist if the devtools are open or closed, and maybe other states, to restore them after a page refresh
  • Render the devtools only when process.env.NODE_ENV === 'development' or using another environment variable
  • Enable tree-shaking the custom Map logic based on the same environment variable used to render the devtools

A more complete example

Further reading

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Arthur Denner

Arthur Denner

Software Engineer using JavaScript. Learning Flutter, Node, MongoDB and GraphQL. Addicted to TV shows, especially nordic. From 🇧🇷, living in 🇸🇪.