Best way to send a message from popup to content and get a response in Plasmo?

Hi everyone, I'm working on a Plasmo extension and trying to send a message from the popup to the content script and receive a response back in the popup. I read that one approach is to use storage as an intermediary—where the content script updates the storage and the popup listens for changes. However, since storage has limitations, I'd like to explore other options. Would chrome.runtime.sendMessage or chrome.tabs.sendMessage be a better approach for direct communication? Or is there another recommended method in Plasmo for handling this? Thanks in advance!
2 Replies
gmasid
gmasid•5mo ago
i replace messaging + storage with zustand to communicate between content script for example. mostly because is synchronous. maybe you can try it for your problem. it’s working pretty well can you give an example of what you’re trying to achieve to see if we find a good approach?
Renasue
RenasueOP•2w ago
I decided to define chrome.runtime.onMessage.addListener in the content script and use chrome.tabs.sendMessage to send messages. It always responds with the result from the content script to the popup. Previously, I created the extension using pure JavaScript without any framework. Now, I’m trying to use Plasmo, but I’ve encountered this issue. I resolved the issue, and it now works as expected. However, I have a question: why doesn’t Plasmo support this by default? My Solution content/plasmo.ts
const actions = {
'action-name': ({ callback }) => callback(getImages())
} as Record<TMessageName, TActionFunc<unknown, unknown>>

chrome.runtime.onMessage.addListener((message: TMessage, _, sendResponse) => {
const callback = actions[message.name]
if (!callback) return
callback({ body: message.body, callback: sendResponse })
})
const actions = {
'action-name': ({ callback }) => callback(getImages())
} as Record<TMessageName, TActionFunc<unknown, unknown>>

chrome.runtime.onMessage.addListener((message: TMessage, _, sendResponse) => {
const callback = actions[message.name]
if (!callback) return
callback({ body: message.body, callback: sendResponse })
})
popup.tsx
...
useEffect(() => {
sendMessage({
name: "action-name",
callback: ({ tabs, response }) => {
console.log(":rocket: ~ useEffect ~ tabs, response:", tabs, response)
}
})
}, [])
...
...
useEffect(() => {
sendMessage({
name: "action-name",
callback: ({ tabs, response }) => {
console.log(":rocket: ~ useEffect ~ tabs, response:", tabs, response)
}
})
}, [])
...
Custom send message function:
export const sendMessage = <D, R>({
name,
body,
callback
}: {
name: TMessageName
body?: D
callback?: (props: { tabs: chrome.tabs.Tab[]; response: R }) => void
}) => {
if (!chrome.tabs) return
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
chrome.tabs.sendMessage(tabs[0].id ?? 0, { name, body }, (response) =>
callback?.({ tabs, response })
)
})
}
export const sendMessage = <D, R>({
name,
body,
callback
}: {
name: TMessageName
body?: D
callback?: (props: { tabs: chrome.tabs.Tab[]; response: R }) => void
}) => {
if (!chrome.tabs) return
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
chrome.tabs.sendMessage(tabs[0].id ?? 0, { name, body }, (response) =>
callback?.({ tabs, response })
)
})
}
@gmasid Hey guys, long time no see. I found a simple way to sync Zustand state between popup and content in a Chrome extension.
import type { Mutate, StoreApi } from "zustand"

type StoreWithPersist<T> = Mutate<StoreApi<T>, [["zustand/persist", unknown]]>

export const withStorageLocalEvent = <T>(
store: StoreWithPersist<T>,
storageType: "local" | "sync" | "session"
) => {
const storageEventCallback = (changes: {
[key: string]: chrome.storage.StorageChange
}) => {
const [key, { newValue }] = Object.entries(changes)[0]
if (key === store.persist.getOptions().name && newValue) {
store.persist.rehydrate()
}
}

chrome.storage[storageType].onChanged.addListener(storageEventCallback)

return () => {
chrome.storage[storageType].onChanged.removeListener(storageEventCallback)
}
}
import type { Mutate, StoreApi } from "zustand"

type StoreWithPersist<T> = Mutate<StoreApi<T>, [["zustand/persist", unknown]]>

export const withStorageLocalEvent = <T>(
store: StoreWithPersist<T>,
storageType: "local" | "sync" | "session"
) => {
const storageEventCallback = (changes: {
[key: string]: chrome.storage.StorageChange
}) => {
const [key, { newValue }] = Object.entries(changes)[0]
if (key === store.persist.getOptions().name && newValue) {
store.persist.rehydrate()
}
}

chrome.storage[storageType].onChanged.addListener(storageEventCallback)

return () => {
chrome.storage[storageType].onChanged.removeListener(storageEventCallback)
}
}
And there is my store
import { create } from "zustand"
import { persist } from "zustand/middleware"

import { STORAGE_KEY } from "~constants"
import { chromeStorageAdapter } from "~stores/adapter/chrome-storage-adapter"
import { createSelectors } from "~stores/selector"
import { withStorageLocalEvent } from "~stores/with-storage-dom-event"

export type Theme = "light" | "dark" | "system"

interface ThemeState {
theme: Theme
setTheme: (theme: Theme) => void
}

export const baseThemeStoreBase = create<ThemeState>()(
persist(
(set) => ({
theme: "system",
setTheme: (theme) => set({ theme })
}),
{
name: STORAGE_KEY.THEME,
storage: chromeStorageAdapter("sync"),
onRehydrateStorage: (state) => {
console.log("🚀 ~ onRehydrateStorage ~ onRehydrateStorage:", state)
}
}
)
)

export const useThemeStore = createSelectors(baseThemeStoreBase)

withStorageLocalEvent(useThemeStore, "sync")
import { create } from "zustand"
import { persist } from "zustand/middleware"

import { STORAGE_KEY } from "~constants"
import { chromeStorageAdapter } from "~stores/adapter/chrome-storage-adapter"
import { createSelectors } from "~stores/selector"
import { withStorageLocalEvent } from "~stores/with-storage-dom-event"

export type Theme = "light" | "dark" | "system"

interface ThemeState {
theme: Theme
setTheme: (theme: Theme) => void
}

export const baseThemeStoreBase = create<ThemeState>()(
persist(
(set) => ({
theme: "system",
setTheme: (theme) => set({ theme })
}),
{
name: STORAGE_KEY.THEME,
storage: chromeStorageAdapter("sync"),
onRehydrateStorage: (state) => {
console.log("🚀 ~ onRehydrateStorage ~ onRehydrateStorage:", state)
}
}
)
)

export const useThemeStore = createSelectors(baseThemeStoreBase)

withStorageLocalEvent(useThemeStore, "sync")
This still creates 2 separate Zustand store instances (one in popup, one in content), but they look synced because we’re rehydrating from chrome.storage on change. There’s a tiny delay (due to the storage event), but it works well enough.

Did you find this page helpful?