Skip to main content
Beat App uses Nanostores for state management—a tiny (less than 1 KB) state manager with excellent TypeScript support and framework-agnostic design. Nanostores provide a simple, performant alternative to Redux or Zustand.

Why Nanostores?

Tiny Bundle Size

Less than 1 KB - minimal impact on app performance

Simple API

Easy to learn with map() and atom() primitives

Framework Agnostic

Works with React, Vue, Svelte, or vanilla JS

TypeScript First

Excellent type inference and safety

Store Architecture

Beat App uses three primary stores located in src/stores/:

Store Responsibilities

StoreFilePurpose
playerStoresrc/stores/playerStore.jsManages playback state, queue, current track, and audio element events
searchStoresrc/stores/searchStore.jsTracks search query and active search tab
appStoresrc/stores/appStore.jsGlobal application state like WebSocket readiness

Player Store

The playerStore is the most complex store, managing all audio playback logic.

Store Definition

import { map, onMount } from "nanostores";
import audioEl from "../audioEl";

const initialState = {
  isPlaying: false,
  currentTrackIndex: -1,
  currentTrack: null,
  queue: [],
  isLoading: false,
  currentTime: 0,
  duration: 0,
  isQueueOpen: false,
  queueDrawerWidth: 0,
};

export const playerStore = map(initialState);
Location: src/stores/playerStore.js:33-46

Store State Shape

  • isPlaying: Boolean indicating if audio is currently playing
  • isLoading: Boolean for buffering/loading states
  • currentTime: Current playback position in seconds
  • duration: Total track duration in seconds
  • currentTrack: Object containing track metadata (title, artists, thumbnailUrl, etc.)
  • currentTrackIndex: Index of current track in queue
  • queue: Array of track objects representing playback queue
  • isQueueOpen: Boolean controlling queue drawer visibility
  • queueDrawerWidth: Pixel width of queue drawer (0 when closed, 300 when open)

Actions Pattern

Nanostores don’t have built-in actions, so Beat App uses a plain object export pattern:
export const playerActions = {
  toggleQueue: () => {
    const state = playerStore.get();
    playerStore.setKey("isQueueOpen", !state.isQueueOpen);
    playerStore.setKey("queueDrawerWidth", state.isQueueOpen ? 0 : 300);
  },

  togglePause: () => {
    if (audioEl.paused) audioEl.play();
    else audioEl.pause();
  },

  playTrack: async (track, newQueue = null) => {
    playerStore.setKey("currentTrack", track);
    playerStore.setKey("isLoading", true);

    if (newQueue) {
      playerStore.setKey("queue", newQueue);
    }

    const state = playerStore.get();
    const trackIndex = state.queue.findIndex(
      (t) => t.trackId === track.trackId
    );

    audioEl.pause();
    updateMediaSessionMetadata(track);

    if (trackIndex !== -1) {
      playerStore.setKey("currentTrackIndex", trackIndex);
      const audioUrl = await getAudioUrl(track.trackId);
      audioEl.src = audioUrl;
      audioEl.play();
    } else {
      const audioUrl = await getAudioUrl(track.trackId);
      audioEl.src = audioUrl;
      playerStore.setKey("currentTrackIndex", 0);
      playerStore.setKey("queue", [track]);
      audioEl.play();
    }
  },
  
  // Additional actions: playNext, playPrevious, addToQueue, etc.
};
Location: src/stores/playerStore.js:49-159

Key Actions

ActionPurposeExample
toggleQueue()Open/close queue drawerplayerActions.toggleQueue()
togglePause()Play or pause current trackplayerActions.togglePause()
playTrack(track, queue?)Play a specific track with optional new queueplayerActions.playTrack(track, tracks)
playNext()Skip to next track in queueplayerActions.playNext()
playPrevious()Go to previous trackplayerActions.playPrevious()
addToQueue(track)Add track to end of queueplayerActions.addToQueue(track)
addToQueueNext(track)Insert track after currentplayerActions.addToQueueNext(track)
seekTo(time)Seek to specific timestampplayerActions.seekTo(45)

Audio Element Integration

The playerStore uses Nanostores’ onMount lifecycle to set up audio element event listeners:
import { onMount } from "nanostores";

onMount(playerStore, () => {
  audioEl.onended = () => {
    playerActions.playNext();
  };

  audioEl.onplay = () => {
    playerStore.setKey("isPlaying", true);
    if ("mediaSession" in navigator) {
      navigator.mediaSession.playbackState = "playing";
    }
  };

  audioEl.onpause = () => {
    playerStore.setKey("isPlaying", false);
    if ("mediaSession" in navigator) {
      navigator.mediaSession.playbackState = "paused";
    }
  };

  audioEl.ontimeupdate = () => {
    playerStore.setKey("currentTime", audioEl.currentTime);
  };

  audioEl.onloadedmetadata = () => {
    playerStore.setKey("duration", audioEl.duration);
  };
  
  // More event handlers...
});
Location: src/stores/playerStore.js:161-222

Audio Event Flow

The audio element events automatically sync state, eliminating manual polling.

Using Stores in Components

Components consume store state using the useStore hook from @nanostores/react:

Basic Usage

import { useStore } from '@nanostores/react';
import { playerStore, playerActions } from '../stores/playerStore';

function Player() {
  const { isPlaying, currentTrack, currentTime, duration } = useStore(playerStore);
  
  return (
    <div>
      <h3>{currentTrack?.title}</h3>
      <button onClick={playerActions.togglePause}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <span>{currentTime} / {duration}</span>
    </div>
  );
}
Real example: src/components/Player.jsx:23-25

Selective Subscriptions

Components only re-render when the specific values they access change:
// This component only re-renders when isPlaying changes
function PlayButton() {
  const { isPlaying } = useStore(playerStore);
  return <button>{isPlaying ? 'Pause' : 'Play'}</button>;
}

// This component only re-renders when queue changes
function QueueDrawer() {
  const { queue } = useStore(playerStore);
  return <div>{queue.length} tracks</div>;
}

TrackList Example

The TrackList component demonstrates reactive state consumption:
import { useStore } from "@nanostores/react";
import { playerStore, playerActions } from "../stores/playerStore.js";

export default function TrackList({ tracks }) {
  const { currentTrack, isPlaying, isLoading } = useStore(playerStore);

  const playTrackItem = (track) => {
    playerActions.playTrack(track, tracks);
  };

  return (
    <List>
      {tracks.map((item, index) => (
        <ListItem onClick={() => playTrackItem(item)}>
          {currentTrack?.trackId === item.trackId ? (
            isLoading ? <CircularProgress /> : 
            isPlaying ? <EqualizerIcon /> : 
            index + 1
          ) : (
            index + 1
          )}
          <ListItemText primary={item.title} />
        </ListItem>
      ))}
    </List>
  );
}
Location: src/components/TrackList.jsx:9-18

Search Store

The search store is much simpler, tracking only search UI state:
import { map } from "nanostores";

export default map({
  search: "",
  activeTab: "all",
});
Location: src/stores/searchStore.js

Usage in Search Components

import { useStore } from '@nanostores/react';
import searchStore from '../stores/searchStore';

function SearchTabs() {
  const { activeTab } = useStore(searchStore);
  
  const handleTabChange = (tab) => {
    searchStore.setKey('activeTab', tab);
  };
  
  return (
    <Tabs value={activeTab} onChange={(e, tab) => handleTabChange(tab)}>
      <Tab value="all" label="All" />
      <Tab value="tracks" label="Tracks" />
      <Tab value="albums" label="Albums" />
      <Tab value="artists" label="Artists" />
    </Tabs>
  );
}

App Store

Minimal global application state:
import { map } from 'nanostores'

export const appStore = map({
  wsReady: false,
})
Location: src/stores/appStore.js
Currently tracks WebSocket connection status for future real-time features.

Best Practices

Export actions alongside the store definition:
export const myStore = map({ ... });
export const myActions = { ... };
This keeps related logic together and makes imports cleaner.
Set up event listeners, subscriptions, or initialization logic in onMount:
onMount(myStore, () => {
  // Setup code
  return () => {
    // Cleanup code
  };
});
To minimize re-renders, only destructure the state values your component actually uses:
// Good - only re-renders on isPlaying changes
const { isPlaying } = useStore(playerStore);

// Bad - re-renders on any playerStore change
const state = useStore(playerStore);
Always use setKey() or set() to update stores:
// Good
playerStore.setKey('isPlaying', true);

// Bad - won't trigger reactivity
playerStore.get().isPlaying = true;

Media Session API Integration

The playerStore integrates with the browser’s Media Session API for native OS controls:
if ("mediaSession" in navigator) {
  navigator.mediaSession.setActionHandler("play", () => 
    playerActions.togglePause()
  );
  navigator.mediaSession.setActionHandler("pause", () => 
    playerActions.togglePause()
  );
  navigator.mediaSession.setActionHandler("previoustrack", () => 
    playerActions.playPrevious()
  );
  navigator.mediaSession.setActionHandler("nexttrack", () => 
    playerActions.playNext()
  );
  navigator.mediaSession.setActionHandler("seekto", (details) => {
    playerActions.seekTo(details.seekTime);
  });
}
Location: src/stores/playerStore.js:209-221
This allows users to control playback from:
  • Operating system media controls
  • Lock screen
  • Keyboard media keys
  • Browser picture-in-picture controls

Performance Considerations

Bundle Size

Nanostores adds minimal overhead:
@nanostores/nanostores: ~1 KB
@nanostores/react: ~0.3 KB
Total: ~1.3 KB gzipped
Compare to alternatives:
  • Redux + React-Redux: ~15 KB
  • Zustand: ~3 KB
  • MobX: ~16 KB

Re-render Optimization

Nanostores only trigger re-renders in components that:
  1. Subscribe to the store via useStore()
  2. Access the specific keys that changed
// Component A - re-renders only when currentTrack changes
function CurrentTrack() {
  const { currentTrack } = useStore(playerStore);
  return <div>{currentTrack?.title}</div>;
}

// Component B - re-renders only when isPlaying changes
function PlayButton() {
  const { isPlaying } = useStore(playerStore);
  return <button>{isPlaying ? 'Pause' : 'Play'}</button>;
}
When playerStore.setKey('currentTime', 45) is called, neither component re-renders because they don’t access currentTime.

Next Steps

Component Structure

Learn how components consume and interact with stores

Nanostores Documentation

Official Nanostores documentation

Build docs developers (and LLMs) love