Build an extendable in-browser devtools
Tags: react, javascript, webdev, devtools
Published: 2022-08-16 16:06:41

---
title: Build an extendable in-browser devtools
description: Learn the fundamental concepts to create an in-browser devtools and empower your team to be more productive.
published: true
date: 2022-08-16 11:00:00 UTC
tags: react, javascript, webdev, devtools
cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/articles/f6qzeely1iniueoc6kwq.png
canonical_url: https://arthurdenner.hashnode.dev/build-an-extendable-in-browser-devtools
---

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](https://github.com/arthurdenner/extendable-in-browser-devtools) and jump straight to the ["Create the devtools" section](#create-the-devtools) 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`](https://chakra-ui.com/) to have a decent layout without much effort and to avoid adding CSS to this post. Following the [Getting Started with Next.js](https://chakra-ui.com/guides/getting-started/nextjs-guide) 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:

```jsx
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:

```jsx
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:

```jsx
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:

```jsx
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:

```jsx
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](https://cdn.hashnode.com/res/hashnode/image/upload/v1657571716038/sUoaW5N_h.gif)

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:

```jsx
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 on both cases.

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

```jsx
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 seconds parameters - since strings are valid React nodes.

```jsx
// 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](https://cdn.hashnode.com/res/hashnode/image/upload/v1657571877830/Xw5WZzLBG.png)

## 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:

```jsx
// 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:

```jsx
// 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](https://cdn.hashnode.com/res/hashnode/image/upload/v1657572358124/ODqtGwB9g.gif)

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](https://extendable-in-browser-devtools-advanced.vercel.app/) and the source code is available on [Gumroad](https://arthurdenner.gumroad.com/l/kdwzj) as a way of supporting my work. Let me know what you think about it in the comments or [via email](mailto:arthurdenner.dev@gmail.com).

## Further reading

- Learn more about the Chrome Devtools in [Umar's excellent blog](https://umaar.com/dev-tips/)
- A collection of useful cross-browser [DevTools tips](https://devtoolstips.org)
- canidev.tools - A caniuse-like website for the browser devtools
- [Make your own DevTools](https://kentcdodds.com/blog/make-your-own-dev-tools) by Kent C. Dodds