Use localStorage for Tab Synchronization

07 March 2024

Updated: 07 March 2024

localStorage

localStorage is a method for persistent data storage in the browser. The storage itself is a simple key-value store with a synchronous API

We can write data to a given key using the following:

1
window.localStorage.setItem('my-key', 'my-data')

And we can get that data back using:

1
const myData = window.localStorage.getItem('my-key')

Pretty neat right?

For the purpose of this post we’ll look into how we can use this as a method for sending data between different browser tabs of the same website

Storage Event

Aside from just data storage - localStorage (as well as sessionStorage) provides us with a notification when the data in a storage is changed - this is called a StorageEvent. What’s particularly interesting to us is that it’s fired in all other tabs of a particular site when any a tab of that site modifies the data

We can listen for this event using window.addEventListener

1
window.addEventListener('storage', () => {
2
console.log('Something has changed in storage')
3
})

Now, if you think about it for a short while, you may come to an interesting conclusion - we can talk to other tabs

Speaking to Tabs

Open this page in two different windows side-by-side for this example

Since we can talk between tabs, we can synchronize parts of our UI using the messages we recieive

Below we have a button that uses this - each time it is pressed in one tab - it updates the count in all tabs

The code for this can be seen below:

SynchronizedButton.astro
1
<button id="sync-button">Count</button>
2
3
<script>
4
const button = document.getElementById('sync-button') as HTMLButtonElement
5
const syncKey = 'button-example-key'
6
7
const getCount = () => {
8
const count = window.localStorage.getItem(syncKey) || '0'
9
10
return parseInt(count)
11
}
12
13
const render = () => {
14
const count = getCount()
15
button.innerText = `Count: ${count}`
16
}
17
18
window.addEventListener('storage', () => render())
19
20
button.addEventListener('click', () => {
21
const count = getCount()
22
const nextCount = count + 1
23
24
// Since the storage event only fires in other tabs we must also render our own tab
25
render()
26
window.localStorage.setItem(syncKey, nextCount.toString())
27
})
28
29
render()
30
</script>

Generic Implementation

So overall, this is a pretty neat method for sharing data between pages, but we end up having to do some repetitive work when setting this up in many places or when working with anything that isn’t a string (as you can see with the parseInts required in the above example)

To make this a little more generic we’re first going to extract the getting and setting of data into their own functions:

1
const getValue = <T>(key: string, initial: T) => {
2
try {
3
const existing = window.localStorage.getItem(key)
4
if (!existing) {
5
return initial
6
}
7
8
return JSON.parse(existing) as T
9
} catch {
10
return initial
11
}
12
}
13
14
const setValue = <T>(key: string, value: T) =>
15
window.localStorage.setItem(key, JSON.stringify(value))

These provide relatively simple wrappers around the reading and writing of data and making it possible for us to work with objects

Next, we can provide an easy way to define a data reader and writer - the reason we define these separately is to make the interface a little easier for consumers to use without needing to rearrange all their code:

1
export type SetValue<T> = (value: T) => void
2
3
export const createSyncReader = <T>(
4
key: string,
5
initial: T,
6
onChange: (value: T) => void
7
) => {
8
window.addEventListener('storage', () => {
9
const value = getValue(key, initial)
10
onChange(value)
11
})
12
13
return () => getValue(key, initial)
14
}
15
16
export const createSyncWriter =
17
<T>(key: string): SetValue<T> =>
18
(value) =>
19
setValue(key, value)

Our reader function will call the provided onChange callback that is provided to it when the data is changed with the parsed data. We can implement the same button as above using this new code as can be seen below:

SynchronizedButtonRefactor.astro
1
<button id="sync-button-refactor">Count</button>
2
3
<script>
4
import { createSyncReader, createSyncWriter } from './sync'
5
6
const button = document.getElementById(
7
'sync-button-refactor'
8
) as HTMLButtonElement
9
const syncKey = 'button-example-key-refactor'
10
11
const render = (count: number) => {
12
button.innerText = `Count: ${count}`
13
}
14
15
const readValue = createSyncReader<number>(syncKey, 0, render)
16
const writeValue = createSyncWriter<number>(syncKey)
17
18
button.addEventListener('click', () => {
19
const count = readValue()
20
const nextCount = count + 1
21
22
// Since the storage event only fires in other tabs we must also render our own tab
23
render(nextCount)
24
writeValue(nextCount)
25
})
26
27
render(readValue())
28
</script>

And for the the sake of being complete, we can see the refactored button running below:

Further Reading