Handle Page State in Astro

This guide won’t cover persistent state management across page navigations. For that, you’ll want to take a look at nanostores. This guide focuses solely on state management within a page

Astro brings the wonderful world of components to static sites. But with components comes the need to share and manage state. Using a Proxy, you can create a simple state store that can be imported into any script in the project.

Creating a State Store

Start by creating a new file called state.js in the src directory. This file will contain the state store. As an example, we’ll use count as a state property.

src/state.js
export const myState = new Proxy(
{
count: 0,
listeners: [],
listen: (fn) => myState.listeners.push(fn),
},
{
set: (target, key, value) => {
if (key === 'listen' || key === 'listeners') return false
target[key] = value
target.listeners.map(fn => fn())
return true
},
}
)

The state object is a Proxy that will intercept any changes to the object in set. listen allows you to add event listeners to the state store. Useful for updating the UI when the state changes.

Using the State Store

Once you’ve defined a state store, you can import it in the script tag of any .astro file in your project. In addition, I believe this works in any UI framework file too, as long as you specify client:only, though I haven’t tested this, as each framework has its own state management solutions.

As an example, in src/pages/index.astro create a script tag and import the state store.

src/pages/index.astro
---
// server magic
---
<p>Count: <span id="count">0</span></p>
<button id="increment">Increment</button>
<script>
import { myState } from '../state.js'
// listen for changes to the state
myState.listen(() => {
console.log(myState.count)
document.getElementById('count').innerText = myState.count
})
// increment the count when the button is clicked
document.getElementById('increment').addEventListener('click', () => {
myState.count++
})
</script>

We can also import the state store in other .astro components and use it there, and state will remain consistent across all components. For example, editing the state in src/components/counter.astro would update the state in src/pages/index.astro.

Typing the State Store

If you’re using TypeScript, you can type the state store and it’s values to make it easier to work with.

src/state.ts
export interface State {
count: number
listeners: Function[]
listen: (fn: Function) => void
}
export const myState: State = new Proxy<State>(
{
count: 0,
listeners: [],
listen: (fn) => myState.listeners.push(fn),
},
{
set: (target, key: keyof State, value) => {
if (key === 'listen' || key === 'listeners') return false
target[key] = value
target.listeners.map(fn => fn())
return true
},
}
)

Use-cases

My original use-case for this was to manage the state between my navigation & sidebar components on mobile. As the button to open the sidebar was on the navigation menu, I needed a way to open the sidebar from the navigation component. Using a Proxy like this, I’m able to listen for changes to the state of the sidebar and update the UI accordingly.

You can use this to manage the state of a modal, form, or any other UI state that has a need to exist over multiple components. Nanostores is great for managing state across page navigations, but if you need to manage state within a page, it’s a little overkill.