Following the previous (Astro) Applying Dark Mode post, let’s look at how to apply dark mode to Astro with global state. Please note that we will be implementing the following features:
- Manage the current theme with global state
- Maintain the state even after refreshing the page
- Add a “system” theme that follows the user’s device theme
- Subscribe to global state in React, Svelte components
Nano Stores
One of the biggest charms of Astro is that it can use various web frameworks such as React, Vue, Svelte, Solid, etc. This is because NextJS only uses React and Nuxt only uses Vue.
However, this diversity can increase the flexibility of the project, but it presents a new challenge of sharing the same state between different frameworks.
Fortunately, the Astro Official Document introduces a state management library suitable for this called Nano Stores.
Nano Stores is, as the name suggests, a “very small storage”.
It boasts a small capacity (298 bytes) and makes it easy to handle states by borrowing the atomic
concept similar to Recoil, Jotai. In addition, Nano Stores introduces its identity as follows:
Nano Stores was created to move logic from components to the store.
Because global states can be used anywhere, you can easily suffer from code fragmentation. It would be very pleasant if you could handle all state-related logic in one store.ts
file.
Then let’s use this to handle the dark mode theme state.
Declaring Global State
First, install the libraries to be used.
bun add nanostores @nanostores/persistent @nanostores/react
shell
nanostores
is a state management core library.
@nanostores/persistent
helps to maintain the state even after refreshing the page using localStorage or sessionStorage.
@nanostores/react
provides a hook that allows re-rendering according to the state in React.
There is no special setting to use Nano Stores. Just declare the state and use it.
I have set constants and types as follows to easily handle the theme.
import { persistentAtom } from '@nanostores/persistent';
export const THEME_MAP = {
light: 'light',
dark: 'dark',
system: undefined,
} as const;
export type ThemeKey = keyof typeof THEME_MAP;
export type ThemeValue = (typeof THEME_MAP)[ThemeKey];
export const STORAGE_THEME_KEY = 'theme' as const;
export const themeStore = persistentAtom<ThemeValue>(
STORAGE_THEME_KEY,
THEME_MAP.system,
);
ts
Subscribing to Global State
When themeStore
changes,
you just need to add or remove the .dark
class to the <html>
tag depending on the state.
You can implement this using the themeStore.subscribe
method.
// ...
const initThemeStoreSubscribe = () => {
const applyTheme = (theme: ThemeValue) => {
if (theme === THEME_MAP.dark) {
document.documentElement.classList.add('dark');
} else if (theme === THEME_MAP.light) {
document.documentElement.classList.remove('dark');
}
};
themeStore.subscribe((theme) => {
applyTheme(theme);
});
};
ts
When in system
state,
you also add logic to apply dark mode according to the user’s device theme.
const initThemeStoreSubscribe = () => {
// ...
const handleMediaQuery = (query: { matches: boolean }) => {
applyTheme(query.matches ? 'dark' : 'light');
};
themeStore.subscribe((theme) => {
if (theme !== THEME_MAP.system) {
applyTheme(theme);
return;
}
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
// Make sure the EventListener is not registered redundantly by removing it before registering it.
mediaQuery.removeEventListener('change', handleMediaQuery);
mediaQuery.addEventListener('change', handleMediaQuery);
handleMediaQuery(mediaQuery);
});
};
ts
When the page is loaded, just run the initThemeStoreSubscribe
function.
If you use onMount
of Nano Store, it’s very easy.
The related logic is executed when the store is mounted
when it is actually used in the UI.
import { onMount } from 'nanostores';
// Don't run it in the SSR.
if (typeof window !== 'undefined') {
onMount(themeStore, initThemeStoreSubscribe);
}
ts
If onMount
feels too magical and you want to directly control the time when state subscription is registered yourself, you can work as follows.
<script>
import { initThemeStoreSubscribe } from '~/libs/stores/theme';
initThemeStoreSubscribe();
</script>
astro
---
// ...
import ThemeFooterScript from '~/components/theme-footer-script.astro';
---
<html>
<!-- ... -->
<body>
<!-- ... -->
<ThemeFooterScript />
</body>
</html>
astro
If you place ThemeFooterScript
at the bottom of body, the script will execute when the page is loaded. Astro optimizes this as a separate module bundle.
React
For setting up Astro in React, refer to @astrojs/react.
Using Nano Stores in React is simpler than you thought.
When using a state, use the useStore
hook provided by @nanostores/react
.
When modifying a state, use themeStore.set
.
This is also applied in the same way in Preact.
import { useStore } from '@nanostores/react';
// ...
import { THEME_MAP, themeStore } from '~/libs/stores/theme';
export default function ThemeDropdown() {
const theme = useStore(themeStore);
return (
<DropdownMenu>
{/* ... */}
<DropdownMenuItem
className="justify-between"
onClick={() => themeStore.set(THEME_MAP.system)}
>
System
{theme === THEME_MAP.system && <DotIcon />}
</DropdownMenuItem>
</DropdownMenu>
);
}
tsx
Svelte
For setting up Astro in Svelte, refer to @astrojs/svelte.
Nano Store is compatible with Svelte’s reactive
syntax.
You can use it directly by attaching $
in front of Store without any special settings.
<script>
import { THEME_MAP, themeStore } from '~/libs/stores/theme';
let isDropdownOpen = false;
// ...
const handleItemClick = (theme) => {
$themeStore = theme;
isDropdownOpen = false;
};
</script>
<div>
{$themeStore}
</div>
<div>
{#each Object.keys(THEME_MAP) as themeKey}
<button on:click={() => handleItemClick(THEME_MAP[themeKey])}>
{themeKey}
</button>
{/each}
</div>
svelte
Conclusion
Now, what if we use the components we made together?
React
Svelte
It seems that the state is synchronized well without any problem. ✨
Actually, Zustand
can also be used in various frameworks.
But it’s a pity that the official document guide is lacking and you have to do the groundwork according to the framework yourself.
On the other hand, Nano Stores seem to be attractive as they can be easily used on various frameworks through the official document guide. The library is lightweight and the code is short. I love the philosophy of bringing state logic from the components to the store. Especially, the onMount
feature struck me as quite radical.
What about you?
Personally, I look forward to using Nano Stores more as the opportunity arises. 🥰