DEV Community

loading...
Cover image for Chrome extensions: Local storage

Chrome extensions: Local storage

paulasantamaria profile image Paula Santamaría ・6 min read

I'm back with another post about Chrome extensions! This time I wanted to explore how to store data locally using the chrome.storage API.

In this post, we're going to add yet another feature to our original extension (Acho, where are we?). This new feature will store the Title and URL of the page each time we call Acho to tell us where we are. We will then list all of the pages and allow the user to navigate to one of them or clear the list.

Here's a quick demo:

When the user opens the popup, a list of the previously visited pages appears. The user can navigate to each page clicking over it, or they can clear the list using a button

So let's get started!

1. Add the storage permission to the manifest.json

As usual, the first thing we need to update is our manifest.json. This time we're going to add the storage permission:

{
    "manifest_version": 2,
    "name": "Acho, where are we?",
    ... 
    "permissions": [
        "tabs",
        "storage"   // 👈
    ]
}
Enter fullscreen mode Exit fullscreen mode

This will allow our extension to use the storage API.

2. Create the Page Service

Since we already know how to reuse code in chrome extensions, we will create the data access logic in a separate class called PageService. Here we will add the following methods:

  • getPages: Will return the list of stored pages.
  • savePage: Will receive the page data and store it.
  • clearPages: Will remove all the pages from the storage.

About the storage API

The chrome.storage API allows us to store objects using a key that we will later use to retrieve said objects. This API is a bit more robust than the localStorage API, but it's not as powerful as an actual database, so we will need to manage some things ourselves.

To save an object we will define a key-value pair and use the set method. Here's an example:

const key = 'myKey';
const value = { name: 'my value' };

chrome.storage.local.set({key: value}, () => {
  console.log('Stored name: ' + value.name);
});
Enter fullscreen mode Exit fullscreen mode

And to retrieve our value we will use the get method and the key:

const key = 'myKey';
chrome.storage.local.get([key], (result) => {
  console.log('Retrieved name: ' + result.myKey.name);
});
Enter fullscreen mode Exit fullscreen mode

Finally, to clear the storage we have two options:

// Completely clear the storage. All items are removed.
chrome.storage.local.clear(() => {
    console.log('Everything was removed');
});

// Remove items under a certain key
const key = 'myKey';
chrome.storage.local.remove([key], (result) => {
  console.log('Removed items for the key: ' + key);
});
Enter fullscreen mode Exit fullscreen mode

Another thing to have in mind when working with this API is error handling. When an error occurs using the get or set methods, the property chrome.runtime.lastError will be set. So we need to check for that value after calling the get/set methods. A few examples:

const key = 'myKey';
const value = { name: 'my value' };

chrome.storage.local.set({key: value}, () => {
    if (chrome.runtime.lastError)
        console.log('Error setting');

    console.log('Stored name: ' + value.name);
});

chrome.storage.local.get([key], (result) => {
    if (chrome.runtime.lastError)
        console.log('Error getting');

    console.log('Retrieved name: ' + result.myKey.name);
});
Enter fullscreen mode Exit fullscreen mode

Don't worry, I promise the actual implementation will be better than a console.log.

And, before we move on to the real implementation, I wanted to show you something else. I like to work with async/await instead of callbacks. So I created a simple function to promisify the callbacks and still handle errors properly. Here it is:

const toPromise = (callback) => {
    const promise = new Promise((resolve, reject) => {
        try {
            callback(resolve, reject);
        }
        catch (err) {
            reject(err);
        }
    });
    return promise;
}

// Usage example: 
const saveData = () => {
    const key = 'myKey';
    const value = { name: 'my value' };

    const promise = toPromise((resolve, reject) => {
        chrome.storage.local.set({ [key]: value }, () => {
            if (chrome.runtime.lastError)
                reject(chrome.runtime.lastError);

            resolve(value);
        });
    });
}

// Now we can await it:
await saveData();
Enter fullscreen mode Exit fullscreen mode

You can replace chrome.storage.local with chrome.storage.sync to sync the data automatically to any Chrome browser where the user is logged into (if they have the sync feature enabled). But keep in mind that there are limits, as specified in the official documentation.

chrome.storage.local also has limits in the amount of data that can be stored, but that limit can be ignored if we include the unlimitedStorage permission in the manifest.json (check the docs).

Let's move on to our actual implementation!

PageService class

As I said before, our PageService will have 3 methods to store, retrieve and remove our pages. So here they are:

const PAGES_KEY = 'pages';

class PageService {

    static getPages = () => {
        return toPromise((resolve, reject) => {
            chrome.storage.local.get([PAGES_KEY], (result) => {
                if (chrome.runtime.lastError)
                    reject(chrome.runtime.lastError);

                const researches = result.pages ?? [];
                resolve(researches);
            });
        });
    }

    static savePage = async (title, url) => {
        const pages = await this.getPages();
        const updatedPages = [...pages, { title, url }];

        return toPromise((resolve, reject) => {

            chrome.storage.local.set({ [PAGES_KEY]: updatedPages }, () => {           
                if (chrome.runtime.lastError)
                    reject(chrome.runtime.lastError);
                resolve(updatedPages);
            });
        });
    }

    static clearPages = () => {
        return toPromise((resolve, reject) => {
            chrome.storage.local.remove([PAGES_KEY], () => {
                if (chrome.runtime.lastError)
                    reject(chrome.runtime.lastError);
                resolve();
            });
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

A few things to notice about this class:

  • We are using the toPromise function we talked about earlier.
  • We are storing an array of pages, so every time we add a new page to the storage, we need to retrieve the entire array, add our new element at the end and replace the original array in storage. This is one of a few options I came up with to work with arrays and the chrome.storage API since it doesn't allow me to directly push a new element to the array.

3. Make our PageService available to our components

As we saw in the previous posts of this series, we need to make some changes to allow our new class to be used by our extension's different components.

First, we will add it as a script to our popup.html so we can later use it in popup.js:

<!-- popup.html -->

<!DOCTYPE html>
<html lang="en">
<head>
    ...
</head>
<body>
    ...
    <script src='popup.js'></script>
    <script src='acho.js'></script>
    <script src='page.service.js'></script> <!-- 👈 -->
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

This will allow us to save pages, retrieve them and clear them from the browser action.

And finally, we'll add it as a background script in our manifest.json so we can also call the savePage method from our background script when the user uses the shortcut:

{
    "manifest_version": 2,
    "name": "Acho, where are we?",
    ...
    "background": {
        "scripts": [ 
            "background.js", 
            "acho.js", 
            "page.service.js" // 👈
        ],
        "persistent": false
    },
    ...
}
Enter fullscreen mode Exit fullscreen mode

4. Update our popup.js

Now let's update our popup.js to add the new features.

document.addEventListener('DOMContentLoaded', async () => {

    const dialogBox = document.getElementById('dialog-box');

    const acho = new Acho();
    const tab = await acho.getActiveTab();
    const bark = acho.getBarkedTitle(tab.title);

    dialogBox.innerHTML = bark;

    // Store page.
    await PageService.savePage(tab.title, tab.url);

    // Display history.
    await displayPages();

    // Clear history.
    const clearHistoryBtn = document.getElementById('clear-history');
    clearHistoryBtn.onclick = async () => {
        await PageService.clearPages();
        await displayPages();
    };
});

const displayPages = async () => {
    const visitedPages = await PageService.getPages();
    const pageList = document.getElementById('page-list');
    pageList.innerHTML = '';

    visitedPages.forEach(page => {
        const pageItem = document.createElement('li');
        pageList.appendChild(pageItem);

        const pageLink = document.createElement('a');
        pageLink.title = page.title;
        pageLink.innerHTML = page.title;
        pageLink.href = page.url;
        pageLink.onclick = (ev) => {
            ev.preventDefault();
            chrome.tabs.create({ url: ev.srcElement.href, active: false });
        };
        pageItem.appendChild(pageLink);
    });
}
Enter fullscreen mode Exit fullscreen mode

So in the previous code, we are using our three methods from PageService to add the current page to the storage, list the pages on the screen and allow the user to navigate them, and clear the list.

We use the displayPages method to display the pages: To do that we retrieve the list of pages and generate a <li> element and an <a> element for each page. It's important to notice that we need to override the onclick event on our <a> element because if we leave the default functionality, the extension will try to load the page inside our popup, which it's not what we want and it will cause an error. Instead, we create a new tab and navigate to the link using chrome.tabs.create.

That's all we need to do to add the new feature to our popup.

5. Saving the page from the background script

Now let's make sure the pages are also stored when we use the command shortcut. To achieve that all we need to do is call the savePage method when the user executes the command:

 //background.js

 chrome.commands.onCommand.addListener(async (command) => {
    switch (command) {
        case 'duplicate-tab':
            await duplicateTab();
            break;
        case 'bark':
            await barkTitle();
            break;
        default:
            console.log(`Command ${command} not found`);
    }
});

const barkTitle = async () => {
    const acho = new Acho();
    const tab = await acho.getActiveTab();

    chrome.tabs.sendMessage(tab.id, {
        tabTitle: tab.title
    });

    await PageService.savePage(tab.title, tab.url); // 👈
}
Enter fullscreen mode Exit fullscreen mode

That's it!

The repo

You can find this and all of the previous examples of this series in my repo:

GitHub logo pawap90 / acho-where-are-we

Acho (a cute pup) tells you the title of the current page on your browser. A sample chrome extension.

Let me know what you think! 💬

Are you working on or have you ever built a Chrome extension?
How do you manage data storage?

Discussion (12)

pic
Editor guide
Collapse
dilbwagsingh profile image
Dilbwag Singh

It was quite easy to follow along and understand as a beginner. But I have a doubt regarding the "page.service.js" file. What is that file actually? If I rename it to say just "page.js" at appropriate places, the code breaks and suddenly it cannot find the PageService.savePage function in "background.js" script. Can anyone explain? Links to further resources on the same will be much appreciated. Thank you.

Collapse
paulasantamaria profile image
Paula Santamaría Author • Edited

Hi Dilbwag! The page.service.js is a regular file containing a class. I just got into the habit of naming my data access files using the suffix .service, but it could've been called page-service.js too, for example.
The reason your code breaks when you change the file's name is that you must also change the references to the file in the following places:

  • manifest.json: Where you declare the background scripts, search for the page.service.js entry at the end of the scripts array and replace it with the new filename (e.g. page.js)
  • popup.html: At the end of the body tag, we added a script tag referencing page.service.js. It should be replaced to reference the new filename.

Updating the filename in those 2 places should do it. Hope it helps! :)

Collapse
dilbwagsingh profile image
Dilbwag Singh • Edited

It does work now. Thank you so much. Also, are you planning on continuing this series for updating it to manifest V3?

Thread Thread
paulasantamaria profile image
Paula Santamaría Author

Yes! You read my mind 😂 I'm actually working on an article about Manifest V3 right now. My plan is to do an overview and then go through the steps to migrate this sample extension to v3 :)

Thread Thread
dilbwagsingh profile image
Dilbwag Singh

Cool😁 Will be waiting for it!!!

Collapse
jacobkim9881 profile image
Jacobkim • Edited

I use local storage for chrome extensions. For me I use this as send state like redux. Local storage is good to save not sensible data on a browser. But I think the point is local storages are 3 for a tab(contents.js), background, and popup. It's nice information!

Collapse
krishan111 profile image
Krishan111

This post is really creative, I have never thought about doing so👍👍

Collapse
paulasantamaria profile image
Paula Santamaría Author

Thank you! 😊

Collapse
nikhilmwarrier profile image
Nikhil M Warrier

Super useful. How can you do the same for a Firefox Addon?

Collapse
paulasantamaria profile image
Paula Santamaría Author

Thanks! I'm planning to cover how to adapt a chrome extension so it works on firefox in a future post :)

Collapse
ra1nbow1 profile image
Matvey Romanov

Pretty nice and useful guide, thanks a lot

Collapse
paulasantamaria profile image
Paula Santamaría Author

Glad you found it useful 😊