DEV Community

Cover image for The Cleanest Way to Manage React Native Audio State: Zustand + TrackPlayer
Abdulwasiu Abdulmuize
Abdulwasiu Abdulmuize

Posted on

The Cleanest Way to Manage React Native Audio State: Zustand + TrackPlayer

Building a robust media player in React Native often involves managing playback state, track information, and user controls. While react-native-track-player handles the heavy lifting of audio playback, keeping your React Native UI in perfect sync with the player’s internal state can be a challenge. This is where state management libraries like Zustand come in.

In this technical blog post, we'll walk through building a powerful auto-sync system between react-native-track-player and Zustand. By the end, you'll have a useTrackPlayerSync() hook that keeps your app’s UI perfectly aligned with the audio player, making your development process smoother and your user experience seamless.


Why Auto-Sync?

Imagine your user:

  • Plays a track, then minimizes the app.
  • Opens the app later – the player UI should reflect the exact state of the background player.
  • Interacts with native controls (e.g., skip forward from the lock screen) – your UI needs to update instantly.

Without a robust sync mechanism, your UI can fall out of sync, leading to a confusing experience. Our auto-sync system solves this!


🧠 Our Goal: The useTrackPlayerSync() Hook

We'll build a reusable hook that:

  • Subscribes to react-native-track-player events
  • Updates Zustand store automatically
  • Handles errors gracefully
  • Is easy to plug into your app

βœ… Step 1: Create Zustand Store

Create a file src/stores/usePlayerStore.ts:

// src/stores/usePlayerStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

export interface PlayerState {
  currentTrackId: string | null;
  playbackState: 'none' | 'ready' | 'playing' | 'paused' | 'stopped' | 'buffering';
  currentPosition: number;
  duration: number;
  volume: number;
  muted: boolean;
  shuffle: boolean;
  repeatMode: 'none' | 'single' | 'all';
  pointA: number | null;
  pointB: number | null;
  loopActive: boolean;
  subscriptionsInitialized: boolean;
  error: string | null;

  setCurrentTrack: (id: string | null) => void;
  setPlaybackState: (s: PlayerState['playbackState']) => void;
  setCurrentPosition: (p: number) => void;
  setDuration: (d: number) => void;
  setVolume: (v: number) => void;
  setMuted: (m: boolean) => void;
  setShuffle: (s: boolean) => void;
  setRepeatMode: (r: PlayerState['repeatMode']) => void;
  setPointA: (a: number | null) => void;
  setPointB: (b: number | null) => void;
  setLoopActive: (l: boolean) => void;
  setSubscriptionsInitialized: (v: boolean) => void;
  clearLoopPoints: () => void;
  setError: (error: string | null) => void;
}

export const usePlayerStore = create<PlayerState>()(
  persist(
    (set) => ({
      currentTrackId: null,
      playbackState: 'none',
      currentPosition: 0,
      duration: 0,
      volume: 1,
      muted: false,
      shuffle: false,
      repeatMode: 'none',
      pointA: null,
      pointB: null,
      loopActive: false,
      subscriptionsInitialized: false,
      error: null,

      setCurrentTrack: (id) => set({ currentTrackId: id }),
      setPlaybackState: (s) => set({ playbackState: s }),
      setCurrentPosition: (p) => set({ currentPosition: p }),
      setDuration: (d) => set({ duration: d }),
      setVolume: (v) => set({ volume: v }),
      setMuted: (m) => set({ muted: m }),
      setShuffle: (s) => set({ shuffle: s }),
      setRepeatMode: (r) => set({ repeatMode: r }),
      setPointA: (a) => set({ pointA: a }),
      setPointB: (b) => set({ pointB: b }),
      setLoopActive: (l) => set({ loopActive: l }),
      setSubscriptionsInitialized: (v) => set({ subscriptionsInitialized: v }),
      clearLoopPoints: () => set({ pointA: null, pointB: null, loopActive: false }),
      setError: (e) => set({ error: e }),
    }),
    {
      name: 'player-store',
    }
  )
);
Enter fullscreen mode Exit fullscreen mode

🧩 Step 2: The useTrackPlayerSync() Hook

Create src/hooks/useTrackPlayerSync.ts:

import { useEffect } from 'react';
import TrackPlayer, {
  Event,
  State as TrackPlayerState,
  Track,
} from 'react-native-track-player';
import { usePlayerStore, PlayerState } from '../stores/usePlayerStore';

function trackStateToString(state: TrackPlayerState): PlayerState['playbackState'] {
  switch (state) {
    case TrackPlayerState.Playing: return 'playing';
    case TrackPlayerState.Paused: return 'paused';
    case TrackPlayerState.Buffering: return 'buffering';
    case TrackPlayerState.Stopped: return 'stopped';
    case TrackPlayerState.Ready: return 'ready';
    case TrackPlayerState.None: return 'none';
    default:
      console.warn(`Unknown state: ${state}`);
      return 'none';
  }
}

export function useTrackPlayerSync() {
  const {
    setCurrentTrack, setPlaybackState, setCurrentPosition,
    setDuration, setVolume, setSubscriptionsInitialized, setError,
  } = usePlayerStore();

  useEffect(() => {
    const setup = async () => {
      setError(null);
      try {
        const activeTrack: Track | null = await TrackPlayer.getActiveTrack();
        const progress = await TrackPlayer.getProgress();
        const volume = await TrackPlayer.getVolume();
        const playbackState = await TrackPlayer.getPlaybackState();

        setCurrentTrack(activeTrack?.id ?? null);
        setCurrentPosition(progress.position);
        setDuration(progress.duration);
        setVolume(volume);
        setPlaybackState(trackStateToString(playbackState.state));
        setSubscriptionsInitialized(true);
      } catch (error: any) {
        console.error("TrackPlayer setup error:", error);
        setError(`Init error: ${error.message || 'Unknown'}`);
        setSubscriptionsInitialized(false);
      }
    };

    setup();

    const subs = [
      TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, (data) => {
        try {
          setCurrentTrack(data.track?.id ?? null);
          setError(null);
        } catch (e: any) {
          setError(`Track change error: ${e.message}`);
        }
      }),
      TrackPlayer.addEventListener(Event.PlaybackState, ({ state }) => {
        try {
          setPlaybackState(trackStateToString(state));
        } catch (e: any) {
          setError(`State error: ${e.message}`);
        }
      }),
      TrackPlayer.addEventListener(Event.PlaybackProgressUpdated, ({ position, duration }) => {
        setCurrentPosition(position);
        setDuration(duration);
      }),
      TrackPlayer.addEventListener(Event.PlaybackError, (err) => {
        setError(`Playback error: ${err.message || err.code}`);
        setPlaybackState('stopped');
      }),
    ];

    return () => {
      subs.forEach((sub) => sub.remove());
      setSubscriptionsInitialized(false);
      setError(null);
    };
  }, [
    setCurrentTrack, setPlaybackState, setCurrentPosition,
    setDuration, setVolume, setSubscriptionsInitialized, setError,
  ]);
}
Enter fullscreen mode Exit fullscreen mode

πŸš€ Step 3: Use the Hook in App

// App.tsx
import React, { useEffect } from 'react';
import { View, Text, ActivityIndicator, StyleSheet } from 'react-native';
import TrackPlayer from 'react-native-track-player';
import { useTrackPlayerSync } from './src/hooks/useTrackPlayerSync';
import { usePlayerStore } from './src/stores/usePlayerStore';

async function setupTrackPlayer() {
  try {
    await TrackPlayer.setupPlayer();
    await TrackPlayer.updateOptions({
      capabilities: [
        TrackPlayer.Capability.Play,
        TrackPlayer.Capability.Pause,
        TrackPlayer.Capability.SkipToNext,
        TrackPlayer.Capability.SkipToPrevious,
        TrackPlayer.Capability.Stop,
      ],
    });
  } catch (error) {
    console.error('TrackPlayer setup failed:', error);
  }
}

export default function App() {
  useEffect(() => {
    setupTrackPlayer();
  }, []);

  useTrackPlayerSync();
  const { playbackState, currentTrackId, currentPosition, duration, error, subscriptionsInitialized } = usePlayerStore();

  return (
    <View style={styles.container}>
      {error && (
        <View style={styles.errorContainer}>
          <Text style={styles.errorText}>Error: {error}</Text>
        </View>
      )}
      {!subscriptionsInitialized ? (
        <ActivityIndicator size="large" />
      ) : (
        <View>
          <Text>Track: {currentTrackId || 'None'}</Text>
          <Text>State: {playbackState}</Text>
          <Text>Time: {currentPosition.toFixed(1)}s / {duration.toFixed(1)}s</Text>
        </View>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  errorContainer: { backgroundColor: '#fee', padding: 10 },
  errorText: { color: '#900' },
});
Enter fullscreen mode Exit fullscreen mode

πŸ” Syncing Volume & Settings from UI

import React, { useEffect } from 'react';
import { Text, View } from 'react-native';
import { usePlayerStore } from '../stores/usePlayerStore';
import TrackPlayer from 'react-native-track-player';

export function PlayerControls() {
  const { volume, muted, repeatMode, setVolume, setMuted, setRepeatMode, setError } = usePlayerStore();

  useEffect(() => {
    const applyVolume = async () => {
      try {
        await TrackPlayer.setVolume(muted ? 0 : volume);
        setError(null);
      } catch (e: any) {
        setError(`Volume error: ${e.message}`);
      }
    };
    applyVolume();
  }, [volume, muted]);

  return (
    <View>
      <Text>Volume: {(volume * 100).toFixed(0)}% {muted ? '(Muted)' : ''}</Text>
      <Text>Repeat Mode: {repeatMode}</Text>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

βœ… Benefits Recap

  • Single source of truth with Zustand
  • Automatic sync using TrackPlayer events
  • Modern API support
  • Error handling baked in
  • Reusability across apps
  • Clean and scalable architecture

πŸŽ‰ Conclusion

By building a useTrackPlayerSync() hook and managing your state with Zustand, your React Native audio player becomes more reliable, resilient, and ready for production.

Happy syncing – and even happier users. 🎧

Top comments (0)