Build an extendable in-browser devtools

Build an extendable in-browser devtools

Learn the fundamental concepts to empower your team.

Β·

9 min read

Featured on Hashnode

Devtools are useful and part of our daily work as developers. If you're developing for the web, you've probably used the browser's devtools to debug issues, test changes, investigate network requests and many other existing features.

While the browser devtools are great, every application has different needs and therefore, could use specific tools. In some scenarios, creating a browser extension is the solution but it requires writing specific code for each browser and, in many cases, you can build a devtools in the web application itself.

In this blog post, you'll learn how to start your own devtools and build the foundation to expand and adapt this knowledge to your current team and projects.

Prepare the project

PS: If you want to skip coding along, feel free to clone the final repository and jump straight to the "Create the devtools" section for explanations.

For demo purposes, we'll use Next.js. Start a new project with npx create-next-app@latest --use-npm. Feel free to remove the --use-npm flag if you prefer to use yarn and adapt all mentioned commands accordingly.

Once the project is created, open the folder on your preferred code editor.

We'll be using components from @chakra-ui/react to have a decent layout without much effort and to avoid adding CSS to this post. Following the Getting Started with Next.js guide, install all necessary dependencies:

npm i @chakra-ui/react @emotion/react@^11 @emotion/styled@^11 framer-motion@^6

Once all dependencies are successfully installed, open the pages/_app.js file and replace its content:

import { ChakraProvider } from '@chakra-ui/react';

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

export default MyApp;

Create a couple of pages

Update the pages/index.js file with the following content:

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;

Create a new pages/layout.js file and paste the following content:

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;

If you run npm run dev and visit localhost:3000, there should be a link on each page that takes you to the other. Now, let's move on to creating the extendable devtools!

Create the devtools

Create a new file at components/Devtools/Devtools.js with the following content:

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;

The component has a piece of state to hold if the devtools are hidden or shown. When hidden, display a button to show it. When shown, display some hardcoded tabs from Chakra UI and a button to hide the devtools.

Now open _app.js and update it to display the devtools. We'll make use of next/dynamic to lazy-load the component and only load it in the client:

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;

With these changes in place, you should be able to see a floating "Show" button that you can click on to open the devtools and play with them.

Devtools Demo Part 1

The tabs on our devtools are hardcoded and useless so far, there's no fun in that! Let's make them dynamic and contextual!

Make the tabs dynamic

We need our devtools UI to update every time there's a new tab or a tab is removed. Instead of pulling in a third-party library for this feature, let's extend the built-in Map.

Create a new file at components/Devtools/tabs.js and paste the following content:

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);
  };
};

Take some time and read through the comments to understand what the code is doing. In summary, the code holds all tabs in a Map and causes a re-render through useDynamicTabs every time the Map changes.

If you're confused about getTabs being passed by reference: both useState and setState/setTabs accept a function as parameter, so getTabs is called implicitly in both cases.

Now let's change the components/Devtools/Devtools.js file to read the dynamic tabs and react to the changes:

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;

Now our devtools will only display the tabs that are registered and their respective panels. Currently, there are none since the dynamic tabs start as an empty map.

To test its dynamism, call registerTab in pages/_app.js passing a string as the first and second parameters - since strings are valid React nodes.

// 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

With the code above, for example, you should get two tabs in the devtools:

Devtools Demo Part 2

Contextual tabs

As shown in the previous example, you can register "global" tabs, that will be visible on every page. However, specific tools can exist in specific contexts. Let's register a tab for each page and make them available only while the page is active.

Open the pages/index.js file and modify it to register a tab after the first render:

// 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;

Open the pages/layout.js file and modify it too:

// 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;

Now open the devtools and notice that the "Index" tab is only available while on the / page. When switching to the /layout page, "Index" is removed and the "Layout" tab is registered.

Devtools Demo Part 3

That's important because we won't have irrelevant UI on the page we're currently working on. It works like that because registerTab returns an "unsubscribe" function and useEffect runs that function when the page/component is unmounted.

Relevant notes

Although I've used React here, the concepts can be applied to Vue, Svelte and others.

Tabs can be registered by specific components too, not only pages. For example, your notification center, when available and visible, can register a tab that allows notifications to be created. The same goes for your theme picker or whatever other component exists on your web application.

These devtools can make HTTP requests, modify cookies and anything possible in a web application since they're just part of your main application. The sky is the limit!

You can also display devtools from third-party libraries within your own, for example, react-query ships a very useful devtools component that you can display globally.

Closing thoughts

As mentioned in the beginning, this blog post is introductory and I didn't cover everything. Here is a list of changes you should consider if you want to take this idea to the next level:

  • 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

If you think something else is relevant too, please leave a comment!

A more complete example

I know the examples in this blog post are very basic and our devtools only have strings. The goal of the blog post is to open your mind to the idea of building custom devtools and I hope it was achieved!

To help you picture the power behind this idea, I've created a more complete example. It uses TypeScript and integrates a few third-party packages. A live demo is available at this link and the source code is available on Gumroad as a way of supporting my work. Let me know what you think about it in the comments or via email.

Further reading